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 ++++++++++++++++++++++++++++---------------------- src/edit.zig | 7 ++++--- src/mouse.zig | 16 +++++++++------ src/panes.zig | 4 ++++ src/pardes.zig | 10 +++++++++ src/tagline.zig | 15 ++++++++++---- test/panes.zig | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 37 deletions(-) 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; diff --git a/src/edit.zig b/src/edit.zig index edc86dd2..42032168 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -131,12 +131,13 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { } /// tag + '\n' + body: the full selectable pane text, a row to a screen row -/// (the tag's lines first, as many as it shows) +/// (the tag's lines first, the ones it shows) pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); const tag = try tagline.tagText(p, arena, pane); - const cut = modal.lineStartOffset(tag, pane.tag_rows); - const shown = if (cut < tag.len) tag[0 .. cut - 1] else tag; + const from = modal.lineStartOffset(tag, pane.tag_top); + const cut = modal.lineStartOffset(tag, pane.tag_top + pane.tag_rows); + const shown = if (cut < tag.len) tag[from .. cut - 1] else tag[from..]; return std.fmt.allocPrint(arena, "{s}\n{s}", .{ shown, try body_layer.bodyText(p, arena, pane) }); } diff --git a/src/mouse.zig b/src/mouse.zig index df657ead..69feea29 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -276,10 +276,13 @@ 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 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)); + // `row` is the header's row on screen, `line` the line of its text + // shown there. + const base = if (p.header_column != null) p.topBarHeight() else 0; + const row: u16 = @intCast(@min(mrow -| base, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); + const line: u16 = @intCast(@min(row + p.header_top, modal.lineCount(text) -| 1)); const bar = modal.lineSlice(text, line); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(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)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = line; t.cur_col = @intCast(at); @@ -310,11 +313,12 @@ 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 text = edit.flatSurface(p, t) catch return; - const line: u16 = @intCast(mrow - if (column != null) p.topBarHeight() else 0); + const row: u16 = @intCast(mrow - if (column != null) p.topBarHeight() else 0); + const line: u16 = @intCast(row + if (focused) p.header_top 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, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + scroll)); if (m.button == config.select_button) { // A clicked header is typed straight into, as a clicked tag is. if (!focused) tagline.enterHeader(p, column); @@ -662,7 +666,7 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; - const row: usize = @intCast(@max(0, lead.r0)); + const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top; 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)); diff --git a/src/panes.zig b/src/panes.zig index 3cd66eb7..356f4913 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -125,6 +125,10 @@ pub const Pane = struct { /// 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, + /// The tag's first line on screen. A tag with more lines than rows + /// scrolls to keep its caret in view while it has the keyboard; pointer + /// rows count from this line, not the tag's first. + tag_top: u16 = 0, 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 c802f0ea..40e49d6a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3771,6 +3771,9 @@ pub const Pardes = struct { /// A mouse sweep is extending the focused header's selection. header_drag: bool = false, header_scroll: usize = 0, + /// The focused header's first line on screen, which follows its caret + /// when it has more lines than rows. + header_top: usize = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -5533,6 +5536,13 @@ pub const Pardes = struct { const lines = std.mem.count(u8, tagline.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; + if (pane.focus == .tag and pane.prompt != .name) { + const cur: usize = @intCast(@max(0, pane.tag.cur_row)); + var top: usize = pane.tag_top; + if (cur < top) top = cur; + if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows; + pane.tag_top = @intCast(@min(top, lines - pane.tag_rows)); + } else pane.tag_top = 0; const cols = @max(1, r.w -| config.GUTTER); 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) { diff --git a/src/tagline.zig b/src/tagline.zig index 1021e9db..a918689b 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -81,10 +81,15 @@ pub fn focusedHeader(p: *Pardes) ?*Text { pub fn revealHeader(p: *Pardes) void { const t = focusedHeader(p) orelse { p.header_scroll = 0; + p.header_top = 0; return; }; const text = edit.flatSurface(p, t) catch return; - const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row))); + const cur: usize = @intCast(@max(0, t.cur_row)); + const rows: usize = if (p.header_column != null) p.columnBarHeight() else p.topBarHeight(); + if (cur < p.header_top) p.header_top = cur; + if (rows > 0 and cur >= p.header_top + rows) p.header_top = cur + 1 - rows; + const line = modal.lineSlice(text, cur); 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; @@ -187,10 +192,11 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { /// 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(tagPrefix(p, pane) catch return null) else 0; + const at = row + pane.tag_top; + const width = if (at == 0) panes.File.displayWidth(tagPrefix(p, pane) catch return null) else 0; if (col < width) return null; - const line = modal.lineSlice(curTail(pane), row); - return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) }; + const line = modal.lineSlice(curTail(pane), at); + return .{ .row = at, .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 @@ -235,6 +241,7 @@ pub fn exitHeader(p: *Pardes) void { p.header_column = null; p.header_drag = false; p.header_scroll = 0; + p.header_top = 0; } /// Give the keyboard to a column's tag, or the workspace's for null, in diff --git a/test/panes.zig b/test/panes.zig index 954e431b..9b800430 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -228,6 +228,66 @@ test "a tag with a newline in it is as tall as its lines, and each line is typed } } +test "a tag with more lines than rows scrolls to keep its caret in view" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + p.presentation.enabled = false; + var many: std.ArrayList(u8) = .empty; + defer many.deinit(std.testing.allocator); + for (1..21) |n| try many.print(std.testing.allocator, "\nL{d}", .{n}); + try pane.appendTag(many.items); + p.update(.tick); + try std.testing.expectEqual(@as(u16, pardes.MAX_TAG_ROWS), pane.tag_rows); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const x = r.x + pardes.TAG_TEXT_INSET; + // To the last line: the tag scrolls so it is the bottom row, caret on it. + p.update(.{ .key = .{ .cp = ':' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'e' } }); + try std.testing.expectEqual(@as(i32, 20), pane.tag.cur_row); + const bottom = try p.render(frame.allocator()); + const tag_y = p.tagTop(pane, r); + const last_row = tag_y + pardes.MAX_TAG_ROWS - 1; + try std.testing.expectEqual(last_row, bottom.cursor.?.y); + try std.testing.expectEqualStrings("L", bottom.at(x, last_row).grapheme()); + try std.testing.expectEqualStrings("2", bottom.at(x + 1, last_row).grapheme()); + try std.testing.expectEqualStrings("0", bottom.at(x + 2, last_row).grapheme()); + // what the pointer sees is what is shown: its first row is line 13 + const text = try pardes.edit.paneText(p, pane); + try std.testing.expectEqualStrings("L13", modal.lineSlice(text, 0)); + ClickCursorTests.click(p, .left, x + 1, tag_y + 1); + try std.testing.expectEqual(@as(i32, 14), pane.tag.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.tag.cur_col); + // Back to the first line brings the prefix back into view. + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + const top = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 0), pane.tag_top); + try std.testing.expectEqual(tag_y, top.cursor.?.y); + try std.testing.expectEqualStrings("/", top.at(x, tag_y).grapheme()); + + // A column tag does the same in its rows. + many.clearRetainingCapacity(); + try many.appendSlice(std.testing.allocator, "New"); + for (1..21) |n| try many.print(std.testing.allocator, "\nC{d}", .{n}); + p.col_tags[0].own = try p.gpa.dupe(u8, many.items); + p.update(.tick); + const rows = p.columnBarHeight(); + try std.testing.expectEqual(@as(u16, pardes.MAX_TAG_ROWS), rows); + pardes.tagline.enterHeader(p, 0); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'e' } }); + const header = try p.render(frame.allocator()); + const header_row = p.topBarHeight() + rows - 1; + try std.testing.expectEqual(header_row, header.cursor.?.y); + try std.testing.expectEqualStrings("C", header.at(p.col_x[0] + pardes.TAG_TEXT_INSET, header_row).grapheme()); + try std.testing.expectEqualStrings("2", header.at(p.col_x[0] + pardes.TAG_TEXT_INSET + 1, header_row).grapheme()); +} + const TagNavigationTests = struct { fn key(p: *Pardes, cp: u21) void { p.update(.{ .key = .{ .cp = cp } }); -- cgit v1.3