From 1912878e753ce12fdc1ac72ae2e83ddfc18df002 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 00:40:42 -0300 Subject: Let the column and workspace tags take a row for each line they hold A header is a Text like a pane's tag, so a newline typed or pasted into it should show rather than hide the lines after the first; the panes below move down to make room, as they do for a taller pane tag. Co-Authored-By: Claude Opus 5.5 --- src/gui/gui.zig | 18 +++--- src/mouse.zig | 24 +++++--- src/pardes.zig | 184 ++++++++++++++++++++++++++++++++++++++------------------ 3 files changed, 152 insertions(+), 74 deletions(-) (limited to 'src') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 140ca1db..7bf45676 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5508,11 +5508,11 @@ const ResolvedCell = struct { const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; -fn topbarPaneBorderHeight(surface: *const pardes.Surface, cell_h: u32, tagline_h: u32) ?u32 { - if (surface.rows <= pardes.TOPBAR_H) return null; +fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 { + if (top == 0 or surface.rows <= top) return null; const height = topbarPaneBorderPixels(cell_h, tagline_h); if (height == 0) return null; - const base = @as(usize, pardes.TOPBAR_H) * surface.cols; + const base = @as(usize, top) * surface.cols; for (surface.cells[base..][0..surface.cols]) |cell| if (!cell.default and cell.style.font_role == .tagline) return height; return null; @@ -5611,7 +5611,7 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 { fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { const chrome = core.chromeTheme(); if (!core.settings.focus_tint) return chrome.tag_bg; - if (row >= pardes.TOPBAR_H and row < pardes.TOPBAR_H + core.columnBarHeight()) { + if (row >= core.topBarHeight() and row < core.topBarHeight() + core.columnBarHeight()) { for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| { if (id != core.active) continue; const x = core.col_x[column]; @@ -7275,8 +7275,10 @@ fn buildOverlay( const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; appendWorkspacePet(g, core, surface, &builder); - if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| { - const y0: f32 = @floatFromInt(g.cell_h - height / 2); + // The workspace tag grows a row per line, so the border follows its foot. + const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H; + if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| { + const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2); const rgb = topbar_pane_border_rgb; builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, @@ -7286,7 +7288,7 @@ fn buildOverlay( }); } if (core) |p| if (p.columnBarHeight() > 0) { - const y = @as(f32, @floatFromInt(pardes.TOPBAR_H + p.columnBarHeight())) * layout.h; + const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h; builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); }; const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; @@ -7589,7 +7591,7 @@ fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: } fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { - const y0 = @as(f32, @floatFromInt(pardes.TOPBAR_H)) * layout.h; + const y0 = @as(f32, @floatFromInt(core.topBarHeight())) * layout.h; const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; // Down through the pixels past the last whole row, as tags run right. const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; diff --git a/src/mouse.zig b/src/mouse.zig index 33646090..4b624dca 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -299,12 +299,15 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { return; }; 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, 0) orelse (mcol -| x)) + p.header_scroll)); + const text = edit.flatSurface(p, t) catch return; + const top = if (p.header_column != null) p.topBarHeight() else 0; + const line: u16 = @intCast(@min(mrow -| top, modal.lineCount(text) -| 1, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); + const bar = modal.lineSlice(text, line); + 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, line) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it - t.cur_row = 0; + t.cur_row = line; t.cur_col = @intCast(at); - t.vsel.active = t.vsel.col != t.cur_col; + t.vsel.active = t.vsel.row != t.cur_row or t.vsel.col != t.cur_col; if (m.kind == .release) p.header_drag = false; return; } @@ -330,19 +333,22 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } const t = if (column) |c| &p.col_tags[c] else &p.global_tag; const focused = p.header_focus and p.header_column == column; - const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0); + const text = edit.flatSurface(p, t) catch return; + const line: u16 = @intCast(mrow - if (column != null) p.topBarHeight() else 0); + if (line > 0 and line >= modal.lineCount(text)) return; + const bar = modal.lineSlice(text, line); 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, 0) 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, line) 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); t.mode = .insert; - t.cur_row = 0; + t.cur_row = line; t.cur_col = @intCast(at); - t.vsel = .{ .active = false, .row = 0, .col = @intCast(at) }; + t.vsel = .{ .active = false, .row = line, .col = @intCast(at) }; p.header_drag = true; } else { - const span = if (focused) Pardes.headerSelection(t, bar) else null; + const span = if (focused) Pardes.headerSelection(t, text, line) else null; const selected = if (span) |sp| (if (at >= sp.lo and at < sp.hi) bar[sp.lo..sp.hi] else wordAtCol(bar, at)) else diff --git a/src/pardes.zig b/src/pardes.zig index 72a282c2..d07539ba 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -252,10 +252,12 @@ pub const TagKind = @import("tag_layer.zig").Kind; /// 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 MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (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; +/// ...and those of a column's (by index) or the workspace's (after them). +pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1); pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -2854,6 +2856,39 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" { try std.testing.expectEqual(@as(usize, 0), p.header_scroll); } +test "a column tag with a newline in it takes a row per line and pushes its panes down" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.col_tags[0].own = try p.gpa.dupe(u8, "New"); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } }); + try std.testing.expectEqualStrings("New\nSnarf", p.columnTag(0)); + try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight()); + const frame = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y); + try std.testing.expectEqual(TOPBAR_H + 1, frame.cursor.?.y); + try std.testing.expectEqual(@as(u8, 'S'), frame.at(TAG_TEXT_INSET, TOPBAR_H + 1).text[0]); + + // A click on the second row lands on the second line. + p.update(.{ .key = .{ .cp = Key.escape } }); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } }); + try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_row); + try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_col); + + // Taking the newline back out gives the panes their row again. + p.update(.{ .key = .{ .cp = Key.home } }); + p.update(.{ .key = .{ .cp = Key.backspace } }); + try std.testing.expectEqualStrings("NewSnarf", p.columnTag(0)); + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); +} + test "tiny screens keep pane space before adding a column tag" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 2, .tty_only = true }); defer p.deinit(); @@ -4064,16 +4099,24 @@ pub const Pardes = struct { return p.global_tag.own orelse p.headerDefault(.workspace); } - /// The workspace tag row's height: `TOPBAR_H` while the setting keeps - /// it, zero once a native shell takes its commands over. Layout and hit - /// testing read this rather than the constant, the way they read + /// The workspace tag's height: a row per line of it while the setting + /// keeps it, zero once a native shell takes its commands over. Layout and + /// hit testing read this rather than the constant, the way they read /// `columnBarHeight`. pub fn topBarHeight(p: *const Pardes) u16 { - return if (p.settings.workspace_tag) TOPBAR_H else 0; + if (!p.settings.workspace_tag) return 0; + const lines = if (p.global_tag.own) |own| std.mem.count(u8, own, "\n") + 1 else TOPBAR_H; + return @intCast(@min(lines, MAX_TAG_ROWS, @max(TOPBAR_H, p.screen_h / 3))); } + /// The column tags' height: a row per line of the tallest one. pub fn columnBarHeight(p: *const Pardes) u16 { - return if (p.settings.column_tags and p.screen_h >= 3) COLBAR_H else 0; + if (!p.settings.column_tags or p.screen_h < 3) return 0; + var lines: usize = COLBAR_H; + for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| { + lines = @max(lines, std.mem.count(u8, own, "\n") + 1); + }; + return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3))); } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { @@ -4093,7 +4136,7 @@ pub const Pardes = struct { return; }; const text = edit.flatSurface(p, t) catch return; - const line = modal.lineSlice(text, 0); + const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row))); const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w); const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len)); if (col < p.header_scroll) p.header_scroll = col; @@ -6064,40 +6107,46 @@ pub const Pardes = struct { } } - // global tagbar: full width, top row — unless a native shell has - // taken its commands over, in which case the row is not ours to draw + // global tagbar: full width, top rows — unless a native shell has + // taken its commands over, in which case the rows are not ours to draw p.revealHeader(); - const global_bar = modal.lineSlice(p.topbar(), 0); + const global = p.topbar(); + const global_rows = p.topBarHeight(); const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, TOPBAR_H, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - _ = s.print(0, 0, s.cols, global_bar[panes.File.rawAtDisplay(global_bar, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, + s.fill(0, 0, s.cols, global_rows, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); + for (0..global_rows) |row| { + const line = modal.lineSlice(global, row); + _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ + .fg = .{ .rgb = chrome.tag_fg }, + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + } } - if (p.pointer_inside and p.hover_row < p.topBarHeight()) { - const bar = global_bar; + if (p.pointer_inside and p.hover_row < global_rows) { + const bar = modal.lineSlice(global, p.hover_row); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), 0); + const cell = s.at(@intCast(col), p.hover_row); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } - if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { + const column_rows = p.columnBarHeight(); + if (column_rows > 0) for (0..p.ncol) |c| { const grip = @min(config.GUTTER, p.col_w[c]); const inset = @min(TAG_TEXT_INSET, p.col_w[c]); const x = p.col_x[c] + inset; const w = p.col_w[c] - inset; + const y0 = global_rows; if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; @@ -6109,25 +6158,28 @@ pub const Pardes = struct { const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], p.topBarHeight(), grip, 1, grip_style); - s.fill(p.col_x[c] + grip, p.topBarHeight(), inset - grip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + s.fill(p.col_x[c], y0, grip, column_rows, grip_style); + s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - s.fill(x, p.topBarHeight(), w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const tag = modal.lineSlice(p.columnTag(c), 0); - _ = s.print(x, p.topBarHeight(), w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); + const tag = p.columnTag(c); + for (0..column_rows) |row| { + const line = modal.lineSlice(tag, row); + _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + } // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row == p.topBarHeight() and p.hover_col >= x and p.hover_col < x + w) { - const bar = tag; + if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { + const bar = modal.lineSlice(tag, p.hover_row - y0); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.topBarHeight()); + const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } @@ -6135,22 +6187,26 @@ pub const Pardes = struct { } }; if (p.focusedHeader()) |t| { - const bar = modal.lineSlice(try edit.flatSurface(p, t), 0); + const text = try edit.flatSurface(p, t); const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) p.topBarHeight() else 0; - if (headerSelection(t, bar)) |span| { + const y: u16 = if (p.header_column != null) global_rows else 0; + const rows = if (p.header_column != null) column_rows else global_rows; + for (0..rows) |row| { + const bar = modal.lineSlice(text, row); + const span = headerSelection(t, text, row) orelse continue; var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y); + const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); cell.style.fg = .{ .rgb = th.sel_fg }; cell.style.bg = .{ .rgb = th.sel_bg }; } } - if (t.cur_row == 0) { + if (t.cur_row >= 0 and t.cur_row < rows) { + const bar = modal.lineSlice(text, @intCast(t.cur_row)); const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = t.mode == .insert }; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; } } @@ -6439,8 +6495,12 @@ pub const Pardes = struct { } // With the workspace tag row handed to native chrome there is no // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) - try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), 0), chrome.tag_bg, chrome.tag_fg); + if (p.settings.workspace_tag) { + for (0..p.topBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + try p.renderHeaderLayer(arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), line), chrome.tag_bg, chrome.tag_fg); + } + } // Each notice is its own tagline-shaped chip over the top of the body. // Going through the same layer machinery the pane and column tags use // is what gives it the tagline's height, its small-font metrics, the @@ -6483,7 +6543,7 @@ pub const Pardes = struct { const fade = motion.alpha; const page = p.theme().bg orelse chrome.tag_bg; const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), .{ + try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), 0, .{ .x = cx, .y = first + @as(u16, pane.notices.row[i]), .w = chip, @@ -6503,22 +6563,31 @@ pub const Pardes = struct { if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { if (p.col_w[column] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight(), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), 0), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + for (0..p.columnBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; + try p.renderHeaderLayer(arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + } }; } - /// The span a header's selection covers on its first line: up to the - /// caret while typing, and over the cursor's own cell in normal mode, - /// where a cursor is a cell. - pub fn headerSelection(t: *const Text, line: []const u8) ?struct { lo: usize, hi: usize } { - if (!t.vsel.active or t.vsel.row != 0 or t.cur_row != 0) return null; - const anchor = @min(@as(usize, @intCast(@max(0, t.vsel.col))), line.len); - const cursor = @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len); - if (t.mode == .insert) return .{ .lo = @min(anchor, cursor), .hi = @max(anchor, cursor) }; - return .{ .lo = @min(anchor, cursor), .hi = modal.nextGrapheme(line, @max(anchor, cursor)) }; - } - - fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { + /// The span a header's selection covers on line `row` of its text, in + /// that line's bytes: up to the caret while typing, and over the + /// cursor's own cell too in normal mode, where a cursor is a cell. + pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } { + if (!t.vsel.active) return null; + const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }); + const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + const lo = @min(anchor, cursor); + const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor)); + const start = modal.lineStartOffset(text, row); + const end = modal.lineEndOffset(text, row); + if (hi <= start or lo >= end) return null; + return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start }; + } + + /// One row of a header or a notice band as a tag layer: `text` is line + /// `line` of what it shows. + fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; if (viewport.w == 0) { @@ -6529,6 +6598,7 @@ pub const Pardes = struct { if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; + layer.line = line; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; @@ -6549,7 +6619,7 @@ pub const Pardes = struct { surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); if (editing) |t| { - if (headerSelection(t, text)) |span| { + if (headerSelection(t, try edit.flatSurface(p, t), line)) |span| { var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { @@ -6558,11 +6628,11 @@ pub const Pardes = struct { } } const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == 0 and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } else if (p.pointer_inside) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id) pointer.col else null) + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) p.hover_col - viewport.x else -- cgit v1.3