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/File.zig | 14 ++-- src/Messages.zig | 8 +- src/Terminal.zig | 12 +-- src/body_layer.zig | 69 ++++++++-------- src/edit.zig | 34 ++++---- src/exec.zig | 14 ++-- src/gui/gui.zig | 34 ++++---- src/look.zig | 2 +- src/mouse.zig | 127 +++++++++++++++------------- src/panes.zig | 6 +- src/pardes.zig | 237 ++++++++++++++++++++++++++++++++--------------------- src/pdf_view.zig | 23 +++--- src/tag_layer.zig | 6 ++ test/output.zig | 4 +- test/panes.zig | 80 +++++++++++++++--- 15 files changed, 406 insertions(+), 264 deletions(-) diff --git a/src/File.zig b/src/File.zig index 22e3c434..262f3ebb 100644 --- a/src/File.zig +++ b/src/File.zig @@ -378,7 +378,7 @@ pub fn gutterWidth(pane: *const Pane) u16 { /// Convert between rendered cells and UTF-8 byte columns. Tag rows always /// need grapheme conversion; file body rows additionally skip the gutter. pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { - if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); + if (row < pane.tag_rows) return rawAtDisplay(line_text, display_col); if (pane.file == null) return rawAtDisplay(line_text, display_col); const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); if (display_col <= prefix) return display_col; @@ -386,7 +386,7 @@ pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, d } pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { - if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); + if (row < pane.tag_rows) return rawDisplayCol(line_text, byte_col); if (pane.file == null) return rawDisplayCol(line_text, byte_col); const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); if (byte_col <= prefix) return byte_col; @@ -1133,7 +1133,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); const context_bg = p.theme().tag_bg; for (0..@min(pane.context_rows, body_h)) |context_row| { for (0..tw) |col| { @@ -1187,7 +1187,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16 // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const total = nlines(p.gpa, f); - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { var base: usize = undefined; @@ -1263,7 +1263,7 @@ pub fn drawWrapMarkers( pane_bg: pardes.Color, ) void { if (tw <= gutterWidth(pane) + 1) return; - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); const marker_fg = p.chromeTheme().lineno; var row: u16 = 0; while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { @@ -1287,9 +1287,9 @@ pub fn paintWordSelection( const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const prefix_width = gutterWidth(pane); - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); var vr: i32 = 0; - while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { + while (vr + @as(i32, pane.tag_rows) < @as(i32, r.h)) : (vr += 1) { const here = pane.wrapAt(vr); if (here.line != row) continue; var hi = word_hi; diff --git a/src/Messages.zig b/src/Messages.zig index 053950da..c7ff09be 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -397,7 +397,7 @@ pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) /// them instead of letting the last one win the row. pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void { pane.notices.len = 0; - if (r.w <= config.GUTTER or r.h <= BOX_H) return; + if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return; // Oldest message line on top, so lines already up do not move when a // new one lands under them; when rows run out the oldest go first. for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot); @@ -409,7 +409,7 @@ pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void { // carrying the cursor: a prompt you cannot see is a prompt you type // into blind. A two-row pane spends its only body row on one notice, // which is what the single shared row used to do anyway. - const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H)); + const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - pane.tag_rows)); const n = &pane.notices; if (n.span() <= room) return; // Too short for the gaps: close them up in screen order, and if even @@ -743,7 +743,7 @@ test "notices stack over the body's top rows without moving them" { defer frame.deinit(); const r = p.rects[id]; - const top = p.bodyTop(r); + const top = p.bodyTop(pane, r); // A message and a leader prefix at once: two chips, in the order // collectNotices chose, each one row tall and each one row further down. @@ -754,7 +754,7 @@ test "notices stack over the body's top rows without moving them" { _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u8, 2), pane.notices.len); // An overlay, not a reservation: the body still starts where it did. - try std.testing.expectEqual(top, p.bodyTop(r)); + try std.testing.expectEqual(top, p.bodyTop(pane, r)); var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { diff --git a/src/Terminal.zig b/src/Terminal.zig index 7b5af696..16d5af26 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1723,7 +1723,7 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo if (comptime !enabled) return; const s = &p.surface; if (pane.terminal == null) return; - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); var filtered_storage: FilteredColors = undefined; const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { const tz_filter = tracy.zone(@src(), "filterInit"); @@ -2081,7 +2081,7 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = p.bodyTop(pane, r); const raw = try p.render(frame.allocator()); try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); @@ -2231,7 +2231,7 @@ test "terminal Filter preserves exact palette-null light theme default roles" { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = p.bodyTop(pane, r); const ordinary = try p.render(frame.allocator()); try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); @@ -2290,7 +2290,7 @@ test "terminal Filter refuses a foreground that would collapse onto the page" { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = p.bodyTop(pane, r); for (0..9) |t| { p.settings.theme = @intCast(t); @@ -2382,7 +2382,7 @@ test "tty ansi colors follow the prompt hug into normal mode" { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = p.bodyTop(pane, r); const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; @@ -2417,7 +2417,7 @@ test "a prompt row hidden end to end paints nothing at all" { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = p.bodyTop(pane, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); diff --git a/src/body_layer.zig b/src/body_layer.zig index e85e10f1..2b2c8a74 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -135,11 +135,11 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P const raw = pane.body.mode == .tty; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); var lines = std.mem.splitScalar(u8, body, '\n'); var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { - if (visible >= r.h -| BOX_H) break; + if (visible >= r.h -| pane.tag_rows) break; const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) @@ -177,10 +177,10 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var visible: i32 = 0; - while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) { + while (visible < @as(i32, r.h -| pane.tag_rows)) : (visible += 1) { const source = pane.wrapAt(visible); const line = edit.pointerSourceLine(pane, terminal_lines, source.line); const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); @@ -224,13 +224,13 @@ pub fn paintPointerSelection( var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; - const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; + const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. - const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; - var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); - const row_x = if (row < BOX_H) r.x + @min(TAG_TEXT_INSET, r.w) else tx; - const row_width = if (row < BOX_H) r.w -| TAG_TEXT_INSET else tw; + const scroll: i32 = if (row < pane.tag_rows) pane.tag_scroll else 0; + var col: i32 = if (pane.file != null and row >= pane.tag_rows) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); + const row_x = if (row < pane.tag_rows) r.x + @min(TAG_TEXT_INSET, r.w) else tx; + const row_width = if (row < pane.tag_rows) r.w -| TAG_TEXT_INSET else tw; while (col <= c1 - scroll and col < row_width) : (col += 1) { const cell = s.at(row_x + @as(u16, @intCast(col)), sy); cell.default = false; @@ -242,8 +242,8 @@ pub fn paintPointerSelection( pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; - if (r.w <= config.GUTTER or r.h <= BOX_H) return; - const viewport_rows = r.h - BOX_H; + if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return; + const viewport_rows = r.h - pane.tag_rows; if (viewport_rows == 0) return; // The canonical fixed grid remains available to terminal clients. The // separate body layer reserves enough rows for any compact font size. @@ -260,24 +260,24 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec const layer = &p.surface.body_layers[id]; const count = logical_rows * width; if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); - const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + BOX_H)); + const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows)); @memset(temporary_cells, .{}); - var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells }; + var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; std.mem.swap(Surface, &p.surface, &temporary); - const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) }; + const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; renderBody(p, arena, pane, synthetic, id, id == p.active, false) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); - const first_row: usize = if (p.settings.tag_bottom) 0 else BOX_H; + const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } layer.pane = @intCast(id); layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(r), .w = width, .h = viewport_rows }; + layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; @@ -295,15 +295,15 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; + const tag_y = p.tagTop(pane, r); // Below the notice bands: they are stacked at the top of the body the // way tree-sitter context rows are, so the body starts under them // rather than being painted over by them. - const body_y = p.bodyTop(r); + const body_y = p.bodyTop(pane, r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; const page_rgb = th.bg orelse th.tag_bg; // body: emulator rows, prompt rows hidden outside tty mode - const body_h = r.h -| BOX_H; + const body_h = r.h -| pane.tag_rows; const body_bottom = r.h; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, @@ -365,7 +365,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id if (sl.state == .done and b != 0) continue; // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. - if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < BOX_H) continue; + if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only @@ -377,13 +377,13 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id th.sel_bg; const lo = @min(pane.body.msel.r0, pane.body.msel.r1); const hi = @max(pane.body.msel.r0, pane.body.msel.r1); - var row: u16 = BOX_H; // never paint the tag row + var row: u16 = pane.tag_rows; // never paint the tag row while (row < body_bottom) : (row += 1) { - const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; + const ar = pane.wrapAt(@as(i32, row) - @as(i32, pane.tag_rows)).line; if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { - const cell = s.at(tx + col, body_y + row - BOX_H); + const cell = s.at(tx + col, body_y + row - pane.tag_rows); cell.default = false; cell.style.bg = .{ .rgb = modal_bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; @@ -415,7 +415,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id else colors.mix(th.sel_bg, page_rgb); var vr: i32 = 0; - while (vr + @as(i32, BOX_H) < @as(i32, body_bottom)) : (vr += 1) { + while (vr + @as(i32, pane.tag_rows) < @as(i32, body_bottom)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); @@ -444,7 +444,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's if (hover_only) continue; // quiet preview preserves the source ink const cw = pane.wrapRow(sr.row, sr.col); - const crow = cw.row + @as(i32, BOX_H); + const crow = cw.row + @as(i32, pane.tag_rows); const ccol = (if (pane.file != null) panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else @@ -453,8 +453,8 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), )) + vpfx; - if (crow >= BOX_H and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) { - const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H))); + if (crow >= pane.tag_rows and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) { + const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - pane.tag_rows))); cell.default = false; cell.style.bg = .{ .rgb = th.sel_fg }; cell.style.fg = .{ .rgb = th.sel_bg }; @@ -471,7 +471,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); - const prow = cwp.row + @as(i32, BOX_H); + const prow = cwp.row + @as(i32, pane.tag_rows); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) @@ -484,10 +484,10 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id ) else ccol; - if (prow >= BOX_H and cx >= 0 and prow < body_bottom and cx < tw) - s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.body.mode == .insert }; + if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw) + s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert }; } else if (panes.Terminal.visibleCursor(pane)) |cur| { - if (cur.y + BOX_H < body_bottom and cur.x < tw) + if (cur.y + pane.tag_rows < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } @@ -496,10 +496,9 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id // The rail covers the notice bands too: nothing else paints those // two gutter columns, so a gap there keeps the frame-wide border // fill and reads as a notch cut out of the scrollbar. - const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; - const rail_h = r.h -| BOX_H; - s.fill(r.x, rail_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, rail_y, 1, rail_h, .{ .bg = pane_bg }); + const rail_h = r.h -| pane.tag_rows; + s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, diff --git a/src/edit.zig b/src/edit.zig index 1822db31..36544d6b 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -128,10 +128,14 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { p.emit(.{ .set_clipboard = {} }); } -/// tag + '\n' + body: the full selectable pane text (row 0 = the tag) +/// tag + '\n' + body: the full selectable pane text, a row to a screen row +/// (the tag's lines first, as many as it shows) pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); - return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try body_layer.bodyText(p, arena, pane) }); + const tag = try p.tagText(arena, pane); + const cut = modal.lineStartOffset(tag, pane.tag_rows); + const shown = if (cut < tag.len) tag[0 .. cut - 1] else tag; + return std.fmt.allocPrint(arena, "{s}\n{s}", .{ shown, try body_layer.bodyText(p, arena, pane) }); } pub fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i32) []const u8 { @@ -144,16 +148,16 @@ pub fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { var gesture = pane.sel[slot]; if (pane.hasPdf()) return; - if (gesture.state != .done or @min(gesture.r0, gesture.r1) < BOX_H) return; + if (gesture.state != .done or @min(gesture.r0, gesture.r1) < pane.tag_rows) return; if (pane.pointerSelection(gesture) != null) return; pane.clearPointerSelection(slot); // Refresh the source map before translating the rendered rectangle. const raw = pane.isTerminal() and pane.body.mode == .tty; const body = try body_layer.bodyText(p, p.scratch.allocator(), pane); const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{}; - const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H); + const first = @max(0, @min(gesture.r0, gesture.r1) - pane.tag_rows); const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows; - const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H); + const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - pane.tag_rows); if (first > last) return; const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1)); errdefer p.gpa.free(rows); @@ -1595,28 +1599,28 @@ const PointerTextSelection = struct { }; pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) ?PointerTextSelection { - if (p.panes[id] == null) return null; + const pane = p.panes[id] orelse return null; const r = p.rects[id]; if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null; - 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 = row >= tag_y and row < tag_y + BOX_H; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const on_tag = row >= tag_y and row < tag_y + pane.tag_rows; if (on_tag and col < r.x + TAG_TEXT_INSET) return null; // A notice chip is chrome: it sits OVER the body's top rows, so a // click inside one belongs to nobody. The rest of those rows is // ordinary body text and still answers normally. - if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and - p.panes[id].?.notices.covers(row - body_y, col)) return null; + if (!on_tag and row >= body_y and row - body_y < pane.notices.span() and + pane.notices.covers(row - body_y, col)) return null; if (!on_tag) if (body_hit) |supplied| { const mapped_hit = mouse.bodyHitForPane(p, id, supplied, false) orelse return null; const c = @as(i32, mapped_hit.col) - config.GUTTER; - const v = @as(i32, mapped_hit.row) + BOX_H; + const v = @as(i32, mapped_hit.row) + pane.tag_rows; return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false }; }; - const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false) else null; + const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false, row - tag_y) else null; const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) + - (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0); - const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); + (if (on_tag) @as(i32, pane.tag_scroll) else 0); + const v: i32 = if (on_tag) @as(i32, row) - @as(i32, tag_y) else @as(i32, row) - @as(i32, body_y) + @as(i32, pane.tag_rows); return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = on_tag, diff --git a/src/exec.zig b/src/exec.zig index 82b3da4a..a673e9e7 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -94,14 +94,14 @@ pub const PointerOperand = struct { }; pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { - const visible = clicked.r0 - @as(i32, BOX_H); + const visible = clicked.r0 - @as(i32, pane.tag_rows); const wrapped = pane.wrapAt(visible); - const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= BOX_H; + const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows; const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; const col = if (raw) @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) - else if (clicked.r0 >= BOX_H) + else if (clicked.r0 >= pane.tag_rows) edit.paneByteAtDisplay( p, &pane.body, @@ -115,7 +115,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand const kept = pane.sel[sel_slot]; const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { - if (clicked.r0 < BOX_H) break :inside false; + if (clicked.r0 < pane.tag_rows) break :inside false; if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; const source_col: usize = @intCast(@max(0, col)); for (rows) |span| { @@ -155,7 +155,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand result.text = edit.currentSelText(p, pane); return result; } - if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { + if (pane.file != null and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = look.expandedWord(line, source_col) orelse return result; @@ -168,9 +168,9 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand const first = pane.wrapRow(row, lo); const last = pane.wrapRow(row, hi - 1); if (first.row >= 0 and last.row >= 0) { - expanded.r0 = first.row + @as(i32, BOX_H); + expanded.r0 = first.row + @as(i32, pane.tag_rows); expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); - expanded.r1 = last.row + @as(i32, BOX_H); + expanded.r1 = last.row + @as(i32, pane.tag_rows); expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index dfd528a3..140ca1db 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1842,6 +1842,7 @@ const Gui = struct { scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) + scroll_body_h: u16 = 0, // and its BODY rows, which a taller tag leaves fewer of scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since scroll_delta: f32 = 0, // finite raw distance waiting for the render batch @@ -4460,11 +4461,11 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { return; } const r = core.rects[id]; - if (r.h <= pardes.BOX_H) { + if (r.h <= pane.tag_rows) { resetScroll(g); return; } - const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pardes.BOX_H); + const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pane.tag_rows); const st = applyScrollDelta(g.scroll_lag, delta); var left = st.rows; while (left != 0) { @@ -4484,14 +4485,15 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } g.scroll_lag = st.lag; g.scroll_rect = r; - const body_y = core.bodyTop(r); + const body_y = core.bodyTop(pane, r); g.scroll_body_y = body_y; + g.scroll_body_h = r.h -| pane.tag_rows; if (st.rows != 0) { const s = &core.surface; g.scroll_edge_len = 0; - if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { + if (r.w > config.GUTTER and r.h > pane.tag_rows and r.x + r.w <= s.cols) { const bw = r.w - config.GUTTER; - const bh = r.h - pardes.BOX_H; + const bh = g.scroll_body_h; const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); const srow = body_y + (if (st.rows > 0) k - 1 else bh - k); if (srow < s.rows) { @@ -4515,11 +4517,11 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard for (surface.panelTracks()) |track| if (track.active() and track.pane == scroll_pane) return 0; const r = g.scroll_rect; - if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0; + const bh = g.scroll_body_h; + if (r.w <= config.GUTTER or bh == 0) return 0; const x0 = r.x + config.GUTTER; const y0 = g.scroll_body_y; const bw = r.w - config.GUTTER; - const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; @@ -4553,7 +4555,7 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h; const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; - const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h; + const ph = @as(f32, @floatFromInt(g.scroll_body_h)) * layout.h; const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); @@ -5617,8 +5619,9 @@ fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { }; } const r = core.rects[core.active]; - const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; - return if (r.h > 0 and row == tag_y and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; + const pane = core.panes[core.active] orelse return chrome.tag_bg; + const tag_y = core.tagTop(pane, r); + return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; } test "compact column and pane underlays share focus tint without tinting the global tag" { @@ -7474,11 +7477,12 @@ fn appendPaneChrome( const chrome = core.chromeTheme(); const page = overlayRgb(core.theme().bg orelse bg_default); for (core.panes, core.rects) |slot, r| { - if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue; + const pane = slot orelse continue; + if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue; if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; - const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y; - const body_y = core.bodyTop(r); - const body_end = body_y + (r.h -| pardes.BOX_H); + const tag_y = core.tagTop(pane, r); + const body_y = core.bodyTop(pane, r); + const body_end = body_y + (r.h -| pane.tag_rows); const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; @@ -7486,7 +7490,7 @@ fn appendPaneChrome( if (cellFontRole(cell) != .tagline) break false; } else true; if (tag_visible) { - const rule_y = @as(f32, @floatFromInt(tag_y + @as(u16, if (core.settings.tag_bottom) 0 else 1))) * layout.h; + const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h; // Like the band above it, the rule of a pane at the right edge // runs on through the pixels past the last whole cell. const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; diff --git a/src/look.zig b/src/look.zig index 6f9f8c6a..8aa2c0a1 100644 --- a/src/look.zig +++ b/src/look.zig @@ -483,7 +483,7 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { /// coords: row 0 is the tag; file panes carry the line-number prefix) pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.body.cur_row, pane.body.cur_col); - const vrow = w.row + @as(i32, BOX_H); + const vrow = w.row + @as(i32, pane.tag_rows); const vcol = if (pane.file != null) panes.File.displayOffset(pane, pane.body.cur_row, w.at, pane.body.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { 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); diff --git a/src/panes.zig b/src/panes.zig index 720f655f..6887008c 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -25,7 +25,7 @@ pub const EditText = struct { text: []u8, row0: i32 }; pub const Pane = struct { /// One mouse selection (block-shaped), per button. c/r are text-area relative; - /// r counts from the tag row (body starts at BOX_H). + /// r counts from the tag's first row (the body starts at Pane.tag_rows). pub const Sel = struct { state: enum { none, dragging, done } = .none, source_id: u64 = 0, @@ -121,6 +121,10 @@ pub const Pane = struct { focus: Focus = .body, /// Display columns hidden to reveal the tag caret; tag addresses stay whole. tag_scroll: u16 = 0, + /// Rows the tag takes on screen: one per line of it, as many as the pane + /// has room for with a body row to spare. Set with the geometry in sync. + /// Pointer rows count from the tag's first, so a body starts at this one. + tag_rows: u16 = 1, cwd: Cwd = .none, hscroll: i32 = 0, // Visible rows map to source lines and raw/display column origins. diff --git a/src/pardes.zig b/src/pardes.zig index 60c41f50..72a282c2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -249,8 +249,13 @@ pub const TagLayer = @import("tag_layer.zig").Layer; pub const TagHit = @import("tag_layer.zig").Hit; pub const TagKind = @import("tag_layer.zig").Kind; /// Panes, then columns, then the workspace bar, then each pane's notice bands. -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; +/// A tag shows at most this many lines; past them it is cut, like a line +/// too long for its width. +pub const MAX_TAG_ROWS = 8; +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + MAX_PANES * (MAX_TAG_ROWS - 1); pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; +/// Where the layers for a pane tag's second and later lines start. +pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -285,16 +290,17 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Tra } if (track) |active| { const box = active.contentBox(); - const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; + const rows: f32 = @floatFromInt(if (active.pane < MAX_PANES) if (p.panes[active.pane]) |pane| pane.tag_rows else BOX_H else BOX_H); + const tag_y = if (p.settings.tag_bottom) box.y + box.h - rows else box.y; if (@as(f32, @floatFromInt(row)) >= tag_y and - @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H))) + @as(f32, @floatFromInt(row)) < tag_y + rows) return box.x; } for (p.panes, 0..) |slot, id| { - if (slot == null) continue; + const pane = slot orelse continue; const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); + const tag_y = p.tagTop(pane, r); + if (row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); } return @floatFromInt(col); } @@ -352,10 +358,10 @@ pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f3 } }; if (p) |core| for (core.panes, 0..) |slot, id| { - if (slot == null) continue; + const pane = slot orelse continue; const r = core.rects[id]; - const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - if (row != tag_y or r.w == 0) continue; + const tag_y = core.tagTop(pane, r); + if (row < tag_y or row >= tag_y + pane.tag_rows or r.w == 0) continue; const left = @as(f32, @floatFromInt(r.x)) * body; const right = @as(f32, @floatFromInt(r.x + r.w)) * body; if (x < left or x >= right) continue; @@ -813,7 +819,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest const band = for (p.surface.tagLayers()) |*layer| { if (layer.cols != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; - try std.testing.expectEqual(p.bodyTop(r), band.viewport.y); + try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y); try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y); // Sized to its message, flush with the pane's right edge, and the text // right aligned a cell short of that edge. @@ -829,7 +835,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows); try std.testing.expectEqual(@as(u16, 0), pane.body_rows); - try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(r)).style.font_role); + try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(pane, r)).style.font_role); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); @@ -4915,15 +4921,14 @@ pub const Pardes = struct { pane.focus = .tag; } - /// Where display column `col` of the tagline (tag scroll included) lands - /// in the tag's own text, or null when it is on the prefix. - pub fn tagOffsetAt(p: *Pardes, pane: *Pane, col: usize) ?usize { - const prefix = p.tagPrefix(pane) catch return null; - const width = panes.File.displayWidth(prefix); + /// Where display column `col` of tag line `row` (tag scroll included) + /// lands in the tag's own text, or null when it is on the prefix, which + /// only the first line has. + pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { + const width = if (row == 0) panes.File.displayWidth(p.tagPrefix(pane) catch return null) else 0; if (col < width) return null; - const tail = curTail(pane); - const line = tail[0 .. std.mem.indexOfScalar(u8, tail, '\n') orelse tail.len]; - return modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)); + const line = modal.lineSlice(curTail(pane), row); + return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) }; } /// Start a name draft for a file that can take one, its caret at display @@ -5822,7 +5827,7 @@ pub const Pardes = struct { } // A sweep over the tag is the tag's while it has the keyboard. const swept = pane.sel[sel_slot]; - if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < BOX_H) { + if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < pane.tag_rows) { pane.clearPointerSelection(sel_slot); pane.sel[sel_slot].state = .none; } @@ -5836,9 +5841,14 @@ pub const Pardes = struct { state.reply_len = 0; }; const r = p.rects[id]; + // The tag takes a row per line, leaving the body one row if it + // had any; a folded pane shows its first line only. + const lines = std.mem.count(u8, curTail(pane), "\n") + 1; + pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); + if (pane.collapsed) pane.tag_rows = BOX_H; const cols = @max(1, r.w -| config.GUTTER); - const rows = @max(1, r.h -| BOX_H); // the tag steals the top row - if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { + const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows + if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { @@ -5905,7 +5915,8 @@ pub const Pardes = struct { if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; - return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); + // A taller tag has a layer per line; the point is on one of them. + return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; } return null; } @@ -5913,8 +5924,13 @@ pub const Pardes = struct { /// The pane's first body row: under the tag when it is on top, over it /// when it is at the bottom. Notice bands do NOT move it -- they are an /// overlay, drawn over the top rows rather than taking them. - pub fn bodyTop(p: *const Pardes, r: Rect) u16 { - return if (p.settings.tag_bottom) r.y else r.y + BOX_H; + pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { + return if (p.settings.tag_bottom) r.y else r.y + pane.tag_rows; + } + + /// The tag's first row: the pane's top, or its last rows with Tagbottom. + pub fn tagTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { + return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y; } fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { @@ -6005,7 +6021,7 @@ pub const Pardes = struct { // taken out of the body the way a tree-sitter context header is. // The chip is as wide as its own message and no wider, so the text // beneath the rest of the row keeps reading as the file it is. - const first = p.bodyTop(r); + const first = p.bodyTop(pane, r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const row = first + @as(u16, pane.notices.row[i]); @@ -6385,26 +6401,41 @@ pub const Pardes = struct { const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; const tag = try p.tagText(arena, pane); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), panes.File.displayWidth(tag) + 1))); - const layer = &p.surface.tag_layers[id]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - const storage = try arena.alloc(Cell, @as(usize, columns) + TAG_TEXT_INSET); + const rows = pane.tag_rows; + // As wide as the widest line it shows, and never narrower than + // its viewport. + var widest: usize = 0; + var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); + const pitch = @as(usize, columns) + TAG_TEXT_INSET; + const storage = try arena.alloc(Cell, pitch * rows); @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = 1, .cells = storage }; + var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; std.mem.swap(Surface, &p.surface, &temporary); - p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { + p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); - @memcpy(layer.cells, storage[TAG_TEXT_INSET..]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (!p.header_focus and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; + // A layer to a line: the first in the pane's own slot, the rest + // in the slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + } } // With the workspace tag row handed to native chrome there is no // workspace layer to render; the menu bar carries its commands. @@ -6422,8 +6453,8 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h <= BOX_H) continue; - const first = p.bodyTop(r); + if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; + const first = p.bodyTop(pane, r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const text = Messages.noticeText(p, pane, i, &leader_buf); @@ -6556,8 +6587,8 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; - 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); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); @@ -6579,7 +6610,7 @@ pub const Pardes = struct { // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; - s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); + s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); s.set(r.x, tag_y, switch (pane.body.mode) { .normal => config.tag_normal, .insert => config.tag_insert, @@ -6600,14 +6631,13 @@ pub const Pardes = struct { // GUI's image pass, kitty's z-order), so a chip over it would be // painted out. Text gets the overlay; a picture gives up the rows. const shown = pane.notices.span(); - const image_h = (r.h -| BOX_H) -| shown; + const image_h = (r.h -| pane.tag_rows) -| shown; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs // past the notice bands so the gutter has no notch in it - const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; - s.fill(r.x, rail_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, rail_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); return; } @@ -6620,8 +6650,9 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; - 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); + const rows = pane.tag_rows; const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; @@ -6629,44 +6660,53 @@ pub const Pardes = struct { const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. - s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); - s.fill(tx, tag_y, tw, BOX_H, .{ + s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); + s.fill(tx, tag_y, tw, rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const whole = try p.tagText(arena, pane); const tail = curTail(pane); const prefix_len = whole.len - tail.len; - // The tagline shows the tag's first line: its prefix and the first - // line of its own text. - const tag = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; - // The keyboard's caret on it, as a byte offset: in its own text when - // the tag has the keyboard, in the path while a name is drafted. - const caret_at: ?usize = if (!active) + // A row per line: the first carries the prefix, the rest are the + // tag's own text alone. + var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); + var split = std.mem.splitScalar(u8, whole, '\n'); + for (lines[0..rows]) |*line| line.* = split.next() orelse ""; + // The keyboard's caret, as a row and a byte offset in that row's + // line: in the tag's own text when the tag has the keyboard, in the + // path while a name is drafted. + const Caret = struct { row: usize, at: usize }; + const caret: ?Caret = if (!active) null else if (pane.prompt == .name) - @min(tag.len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) - else if (pane.focus == .tag and pane.tag.cur_row == 0) - @min(tag.len, prefix_len + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) - else - null; - if (caret_at) |at| { - const caret = panes.File.rawDisplayCol(tag, at); + .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } + else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: { + const row: usize = @intCast(@max(0, pane.tag.cur_row)); + const lead: usize = if (row == 0) prefix_len else 0; + break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + } else null; + if (caret) |at| { + const line = lines[at.row]; + const col = panes.File.rawDisplayCol(line, at.at); var scroll: usize = pane.tag_scroll; - if (caret < scroll) scroll = caret; - if (caret >= scroll + capacity) scroll = caret - capacity + 1; - var raw = panes.File.rawAtDisplay(tag, scroll); - scroll = panes.File.rawDisplayCol(tag, raw); - if (caret >= scroll + capacity) { - raw = modal.nextGrapheme(tag, raw); - scroll = panes.File.rawDisplayCol(tag, raw); + if (col < scroll) scroll = col; + if (col >= scroll + capacity) scroll = col - capacity + 1; + var raw = panes.File.rawAtDisplay(line, scroll); + scroll = panes.File.rawDisplayCol(line, raw); + if (col >= scroll + capacity) { + raw = modal.nextGrapheme(line, raw); + scroll = panes.File.rawDisplayCol(line, raw); } pane.tag_scroll = @intCast(scroll); } else if (pane.focus != .tag) pane.tag_scroll = 0; - const tag_start = panes.File.rawAtDisplay(tag, pane.tag_scroll); - _ = s.print(tx, tag_y, tw, tag[tag_start..], tag_style); + for (lines[0..rows], 0..) |line, row| { + const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; if (tagTintRange(pane, tag, prefix_len)) |name| { - const lo = @max(name.lo, tag_start); + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); const hi = @min(name.hi, tag.len); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; @@ -6677,41 +6717,50 @@ pub const Pardes = struct { } } if (p.look_hover_preview) |preview| { - if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } - // The tag's own selections, while it has the keyboard. A range of one - // grapheme is the block cursor, not a selection. + // The tag's own selections, while it has the keyboard, a line at a + // time. A range of one grapheme is the block cursor, not a selection. if (pane.focus == .tag) { var ranges: [Text.max_selections]modal.Selection = undefined; const got = pane.tag.ranges(tail, 0, &ranges); - const shown = tag.len - prefix_len; for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(tail, lo) >= hi or lo >= shown) continue; - var col = panes.File.rawDisplayCol(tag, prefix_len + lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, prefix_len + @min(hi, shown)); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; + if (modal.nextGrapheme(tail, lo) >= hi) continue; + const first = modal.positionAt(tail, lo); + const last = modal.positionAt(tail, hi); + var row = first.row; + while (row <= last.row and row < rows) : (row += 1) { + const line = lines[row]; + const lead: usize = if (row == 0) prefix_len else 0; + const from = lead + if (row == first.row) first.col else 0; + const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); + var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(line, to); + const y = tag_y + @as(u16, @intCast(row)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } } } } // the caret: bar while typing, block in normal mode (same rule as a body) - if (caret_at) |at| { - const col = panes.File.rawDisplayCol(tag, at) -| pane.tag_scroll; + if (caret) |at| { + const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = mode == .insert }; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= BOX_H) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } @@ -6945,7 +6994,7 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; - const row = p.bodyTop(r); + const row = p.bodyTop(pane, r); try std.testing.expectEqual(@as(u8, 1), pane.notices.len); try std.testing.expectEqual(row, cursor.y); try std.testing.expect(cursor.x < r.x + r.w); @@ -7010,7 +7059,7 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); const grid = p.surface.cursor orelse return error.MissingPromptCursor; - try std.testing.expectEqual(p.bodyTop(p.rects[0]), grid.y); + try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y); // Submitting searches for what was typed and gives the prompt its line back. p.update(.{ .key = .{ .cp = Key.enter } }); diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 92a05618..b2fd5866 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2170,7 +2170,7 @@ pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewp if (slot == pane) break core.rects[id]; } else return null; const cols = rect.w -| config.GUTTER; - const rows = rect.h -| pardes.BOX_H; + const rows = rect.h -| pane.tag_rows; if (cols == 0 or rows == 0) return null; return .{ .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), @@ -2208,8 +2208,8 @@ pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: const rect = for (core.panes, 0..) |slot, id| { if (slot == pane) break core.rects[id]; } else return null; - const body_y = core.bodyTop(rect); - if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; + const body_y = core.bodyTop(pane, rect); + if (row < body_y or row >= body_y + (rect.h -| pane.tag_rows)) return null; const local_y = @as(f64, @floatFromInt( @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, )); @@ -2265,7 +2265,7 @@ pub fn panePointAtPage( if (slot == pane) break core.rects[id]; } else return null; const body_x = @as(i64, rect.x + config.GUTTER); - const body_y = @as(i64, core.bodyTop(rect)); + const body_y = @as(i64, core.bodyTop(pane, rect)); const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; return pointAtPage(state, view, page, px, py, clamp_to_page); @@ -2549,7 +2549,7 @@ pub fn draw( text_width: u16, ) bool { if (comptime !enabled) return false; - if (!core.native_images or rect.h <= pardes.BOX_H) return false; + if (!core.native_images or rect.h <= pane.tag_rows) return false; const state = &(pane.pdf orelse return false); const view = paneViewport(core, pane) orelse return false; const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; @@ -2598,9 +2598,9 @@ pub fn draw( .x = text_x, // Below the notice chips, like an image: a placed page is // drawn after the cells and would paint a chip out. - .y = core.bodyTop(rect) + pane.notices.span(), + .y = core.bodyTop(pane, rect) + pane.notices.span(), .w = text_width, - .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(), + .h = (rect.h -| pane.tag_rows) -| pane.notices.span(), .rgba = placed.rgba, .iw = placed.width, // The texture contains the retained band, not the full page. @@ -2616,15 +2616,14 @@ pub fn draw( // This frame has spent the motion used to choose its raster band. state.scroll_travel = 0; - const body_y = core.bodyTop(rect); - const body_h = rect.h -| pardes.BOX_H; + const body_y = core.bodyTop(pane, rect); + const body_h = rect.h -| pane.tag_rows; const chrome = core.chromeTheme(); const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; // The rail runs past the notice bands; only the thumb tracks the text. - const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); + core.surface.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + core.surface.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg }); const track_h: usize = body_h; const total = @max(@as(u64, 1), state.document_height); diff --git a/src/tag_layer.zig b/src/tag_layer.zig index 52da297d..ac7c4ac2 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -13,6 +13,8 @@ pub const Hit = struct { id: u16, serial: u32, col: u16, + /// Which line of a taller tag the layer draws. + line: u16 = 0, pixel_x: f32, pixel_y: f32, metrics: pardes.RowMetrics, @@ -21,6 +23,9 @@ pub const Layer = struct { kind: Kind = .workspace, id: u16 = 0, serial: u32 = 0, + /// A pane's tag taller than a line is drawn a layer to a line; this is + /// which one, counted from its first. + line: u16 = 0, viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, cols: u16 = 0, bg: [3]u8 = .{ 0, 0, 0 }, @@ -54,6 +59,7 @@ pub const Layer = struct { .kind = layer.kind, .id = layer.id, .serial = layer.serial, + .line = layer.line, .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))), .pixel_x = x, .pixel_y = y, diff --git a/test/output.zig b/test/output.zig index c245e5f5..400e9508 100644 --- a/test/output.zig +++ b/test/output.zig @@ -77,7 +77,7 @@ fn outputCell(p: *Pardes, surface: *pardes.Surface, id: usize, byte: usize) *par const line = modal.lineSlice(pane.file.?.content, 0); const rect = p.rects[id]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + @as(u16, @intCast(panes.File.displayWidth(line[0..byte]))); - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); return surface.at(x, y); } @@ -490,7 +490,7 @@ test "Mini syntax colors survive toggles themes and rendering without source acc const surface = try p.render(frame.allocator()); const rect = p.rects[id]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(mini) + @as(u16, @intCast(column)); - const y = p.bodyTop(rect) + @as(u16, @intCast(position.row)); + const y = p.bodyTop(mini, rect) + @as(u16, @intCast(position.row)); if (enabled) try std.testing.expectEqualDeep(pardes.Color{ .rgb = p.theme().kw }, surface.at(x, y).style.fg); try std.testing.expectEqual(colors, mini.file.?.mini.?.colors.ptr); try std.testing.expectEqual(@as(usize, 0), mini.file.?.highlights.len); diff --git a/test/panes.zig b/test/panes.zig index b9fa9a88..4804c497 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -168,6 +168,66 @@ const TagLayerTests = struct { } }; +test "a tag with a newline in it is as tall as its lines, and each line is typed into and pointed at" { + for ([_]bool{ false, true }) |bottom| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 14 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + p.presentation.enabled = false; + p.settings.tag_bottom = bottom; + p.update(.tick); + try std.testing.expectEqual(@as(u16, 1), pane.tag_rows); + const height = p.rects[0].h; + const body_rows = pane.rows; + // Insert mode in a tag is a body's: Enter is a new line. + p.update(.{ .key = .{ .cp = ':' } }); + p.update(.{ .key = .{ .cp = 'A' } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + p.update(.{ .key = .{ .cp = 'S', .text = "Second" } }); + try std.testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), "\nSecond")); + try std.testing.expectEqual(@as(u16, 2), pane.tag_rows); + // The pane keeps its height; the body gives the tag its row. + try std.testing.expectEqual(height, p.rects[0].h); + try std.testing.expectEqual(body_rows - 1, pane.rows); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const r = p.rects[0]; + const tag_y = p.tagTop(pane, r); + const x = r.x + pardes.TAG_TEXT_INSET; + try std.testing.expectEqualStrings("/", surface.at(x, tag_y).grapheme()); + for ("Second", 0..) |byte, i| try std.testing.expectEqual(byte, surface.at(x + @as(u16, @intCast(i)), tag_y + 1).text[0]); + try std.testing.expectEqual(pardes.FontRole.tagline, surface.at(x, tag_y + 1).style.font_role); + // the caret is on the second line, after what was typed + try std.testing.expectEqual(tag_y + 1, surface.cursor.?.y); + try std.testing.expectEqual(x + 6, surface.cursor.?.x); + // the body starts under (or over) the whole tag + const body_y = p.bodyTop(pane, r); + try std.testing.expectEqual(if (bottom) r.y else r.y + 2, body_y); + try std.testing.expectEqualStrings("b", surface.at(r.x + config.GUTTER + panes.File.gutterWidth(pane), body_y).grapheme()); + // a layer to a line, for a pixel shell + var lines: usize = 0; + for (p.surface.tagLayers()) |*layer| { + if (layer.cols == 0 or layer.kind != .pane or layer.id != 0) continue; + try std.testing.expectEqual(tag_y + layer.line, layer.viewport.y); + lines += 1; + } + try std.testing.expectEqual(@as(usize, 2), lines); + // a click on the second line puts the tag's cursor on it + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.{ .key = .{ .cp = ':' } }); + ClickCursorTests.click(p, .left, x + 2, tag_y + 1); + try std.testing.expect(pane.focus == .tag); + try std.testing.expectEqual(@as(i32, 1), pane.tag.cur_row); + try std.testing.expectEqual(@as(i32, 2), pane.tag.cur_col); + // and the pane's text, a row to a screen row, has both of its lines + const text = try pardes.edit.paneText(p, pane); + try std.testing.expect(std.mem.startsWith(u8, modal.lineSlice(text, 0), "/test.txt")); + try std.testing.expectEqualStrings("Second", modal.lineSlice(text, 1)); + try std.testing.expectEqualStrings("body\n", pane.file.?.content); + } +} + const TagNavigationTests = struct { fn key(p: *Pardes, cp: u21) void { p.update(.{ .key = .{ .cp = cp } }); @@ -521,7 +581,7 @@ const TreeContextTests = struct { try std.testing.expectEqual(@as(i32, 59), pane.wrapAt(visible.row).line); const cursor = surface.cursor orelse return error.MissingLookCursor; const rect = p.rects[0]; - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); try std.testing.expectEqual(y + @as(u16, @intCast(visible.row)), cursor.y); } } @@ -543,7 +603,7 @@ const TreeContextTests = struct { try std.testing.expectEqual(@as(i32, 7), pane.wrapAt(pane.context_rows).line); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); ClickCursorTests.click(p, .left, x + 6, y); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 6), pane.body.cur_col); @@ -552,7 +612,7 @@ const TreeContextTests = struct { try std.testing.expectEqual(x + 6, cursor.x); // Read the body top again: the click cleared the announcement band the // builtin above put up, and the body moved back up with it. - try std.testing.expectEqual(p.bodyTop(p.rects[0]), cursor.y); + try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), cursor.y); p.update(.{ .key = .{ .cp = 'i' } }); p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); try std.testing.expect(std.mem.startsWith(u8, pane.file.?.content, "const ZBox = struct {")); @@ -591,7 +651,7 @@ const TreeContextTests = struct { const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 3), pane.context_rows); const rect = p.rects[0]; - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); var glyph_x: ?u16 = null; for (0..surface.cols) |col| { if (std.mem.eql(u8, surface.at(@intCast(col), y).grapheme(), "界")) glyph_x = @intCast(col); @@ -605,7 +665,7 @@ const TreeContextTests = struct { try std.testing.expectEqual(x, cursor.x); // Read the body top again: the click cleared the announcement band the // builtin above put up, so the body moved with it. - try std.testing.expectEqual(p.bodyTop(p.rects[0]), cursor.y); + try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), cursor.y); panes.File.setContent(p, &pane.file.?, try p.gpa.dupe(u8, "// no enclosing declaration\n" ** 18)); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 0), pane.context_rows); @@ -664,7 +724,7 @@ const TtySelectionTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER; - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); JumpSelectionTests.drag(p, x + 1, y, x + 3, y); try std.testing.expectEqualStrings("lph", p.yank orelse return error.MissingAutomaticTtyYank); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); @@ -694,7 +754,7 @@ const TtySelectionTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER; - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); JumpSelectionTests.drag(p, x + @as(u16, if (prompt) 0 else 1), y, x + 3, y + @as(u16, if (prompt) 0 else 1)); const expected = if (prompt) "$ ab" else "lph\nrav"; try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); @@ -1009,7 +1069,7 @@ const JumpSelectionTests = struct { if (wrap) try std.testing.expectEqual(pane.wrap_line[0], pane.wrap_line[1]); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); - const y = p.bodyTop(rect); + const y = p.bodyTop(pane, rect); if (reverse) drag(p, x + last_col, y + 1, x + first_col, y) else @@ -1040,7 +1100,7 @@ const JumpSelectionTests = struct { // A selection created after Back must also disappear on Forward. // Back announces itself, so the body sits under a notice band now. - const y2 = p.bodyTop(p.rects[0]); + const y2 = p.bodyTop(pane, p.rects[0]); drag(p, x + 1, y2, x + 3, y2 + 1); try std.testing.expect(pardes.test_api.heldSelection(p, 0) != null); p.update(.{ .key = .{ .cp = 'i', .ctrl = true } }); @@ -4093,7 +4153,7 @@ test "cancelled normal gestures leave no count or subprefix after mouse and focu .body, .tag => { const rect = p.rects[0]; const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; - const body_y = p.bodyTop(rect); + const body_y = p.bodyTop(pane, rect); const x = if (gesture == .tag) rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(try p.tagPrefix(pane)))) + 3 else -- cgit v1.3