From 9d2e07e997de155c7dbb894a79e5c11839eab39e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 01:01:35 -0300 Subject: Scroll a tag taller than its rows to keep its caret in view A tag shows at most eight rows, so a longer one (a 9P write of many lines, or `o` in the tag) put its caret on a line that was never drawn. Pane, column and workspace tags now keep a first line on screen that follows the caret while they have the keyboard, and the pointer and the pane text count their rows from it. Co-Authored-By: Claude Opus 5.5 --- src/body_layer.zig | 56 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 24 deletions(-) (limited to 'src/body_layer.zig') diff --git a/src/body_layer.zig b/src/body_layer.zig index ab000795..04c84b62 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -569,13 +569,14 @@ pub fn paintHeaders(p: *Pardes) !void { const global = tagline.topbar(p); const global_rows = p.topBarHeight(); const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; + const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; if (p.settings.workspace_tag) { 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); + const line = modal.lineSlice(global, row + global_top); _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, @@ -584,7 +585,7 @@ pub fn paintHeaders(p: *Pardes) !void { } } if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row); + const bar = modal.lineSlice(global, p.hover_row + global_top); 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; @@ -619,9 +620,10 @@ pub fn paintHeaders(p: *Pardes) !void { 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 bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; const tag = tagline.columnTag(p, c); for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row); + const line = modal.lineSlice(tag, row + bar_top); _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, @@ -630,7 +632,7 @@ pub fn paintHeaders(p: *Pardes) !void { } // 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 >= 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); + const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); 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; @@ -649,8 +651,8 @@ pub fn paintHeaders(p: *Pardes) !void { 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 = tagline.headerSelection(t, text, row) orelse continue; + const bar = modal.lineSlice(text, row + p.header_top); + const span = tagline.headerSelection(t, text, row + p.header_top) 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) { @@ -659,10 +661,11 @@ pub fn paintHeaders(p: *Pardes) !void { cell.style.bg = .{ .rgb = th.sel_bg }; } } - if (t.cur_row >= 0 and t.cur_row < rows) { - const bar = modal.lineSlice(text, @intCast(t.cur_row)); + const cur: usize = @intCast(@max(0, t.cur_row)); + if (cur >= p.header_top and cur < p.header_top + rows) { + const bar = modal.lineSlice(text, cur); 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 + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; } } } @@ -704,6 +707,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { // its viewport. var widest: usize = 0; var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..pane.tag_top) |_| _ = split.next(); 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; @@ -740,7 +744,8 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { 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 renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line), chrome.tag_bg, chrome.tag_fg); + const top = if (p.header_focus and p.header_column == null) p.header_top else 0; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); } } // Each notice is its own tagline-shaped chip over the top of the body. @@ -805,9 +810,10 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { 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; + const top = if (p.header_focus and p.header_column == column) p.header_top else 0; 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 renderHeaderLayer(p, 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(tagline.columnTag(p, column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + try renderHeaderLayer(p, 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(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); } }; } @@ -846,7 +852,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T 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 (tagline.headerSelection(t, try edit.flatSurface(p, t), line)) |span| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |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) { @@ -855,7 +861,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T } } const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + if (t.cur_row == line + p.header_top 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| @@ -899,10 +905,12 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, const whole = try tagline.tagText(p, arena, pane); const tail = tagline.curTail(pane); const prefix_len = whole.len - tail.len; - // A row per line: the first carries the prefix, the rest are the - // tag's own text alone. + // A row per line shown, from `tag_top`: the tag's first line carries + // the prefix, the rest are the tag's own text alone. + const top = pane.tag_top; var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); var split = std.mem.splitScalar(u8, whole, '\n'); + for (0..top) |_| _ = split.next(); 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 @@ -912,9 +920,9 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, null else if (pane.prompt == .name) .{ .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; + else if (pane.focus == .tag and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { + const row: usize = @intCast(pane.tag.cur_row - top); + const lead: usize = if (row + top == 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| { @@ -936,7 +944,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, _ = 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| { + if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); const hi = @min(name.hi, tag.len); if (lo < hi) { @@ -946,7 +954,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; } - } + }; 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) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); @@ -964,15 +972,15 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, 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]; + var row = @max(first.row, top); + while (row <= last.row and row < top + rows) : (row += 1) { + const line = lines[row - top]; 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)); + const y = tag_y + @as(u16, @intCast(row - top)); while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; -- cgit v1.3