summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig1264
1 files changed, 1264 insertions, 0 deletions
diff --git a/src/draw.zig b/src/draw.zig
new file mode 100644
index 00000000..2d757635
--- /dev/null
+++ b/src/draw.zig
@@ -0,0 +1,1264 @@
+//! The frame: how the core builds the canonical Surface every shell draws.
+//! `render` is the whole order, top to bottom: the page, each pane (its
+//! grip, tag and body, and the body layer of its compact rows), the notice
+//! chips, the workspace and column tags, the tag layers, drag overlays, the
+//! debug box, then the presentation's tracks and the character effects.
+const std = @import("std");
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const config = @import("config.zig");
+const panes = @import("panes.zig");
+const modal = @import("modal.zig");
+const tracy = @import("tracy.zig");
+const colors = @import("colors.zig");
+const edit = @import("edit.zig");
+const mouse = @import("mouse.zig");
+const tagline = @import("tagline.zig");
+const body_layer = @import("body_layer.zig");
+const Messages = @import("Messages.zig");
+const Color = @import("surface.zig").Color;
+const FontRole = @import("surface.zig").FontRole;
+const CellStyle = @import("surface.zig").CellStyle;
+const Cell = @import("surface.zig").Cell;
+const Surface = @import("surface.zig").Surface;
+const Pardes = pardes.Pardes;
+const Pane = panes.Pane;
+const Text = panes.Text;
+const Rect = layout.Rect;
+const Region = @import("surface.zig").Region;
+const TagKind = @import("Layer.zig").Kind;
+const pdf_enabled = panes.Pdf.enabled;
+const wordBoundsAtCol = pardes.wordBoundsAtCol;
+const BOX_H = pardes.BOX_H;
+const TAG_GAP = pardes.TAG_GAP;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const TOPBAR_H = pardes.TOPBAR_H;
+const COLBAR_H = pardes.COLBAR_H;
+const MAX_PANES = pardes.MAX_PANES;
+const MAX_COLS = pardes.MAX_COLS;
+const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS;
+const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE;
+
+/// Where every part of the frame goes, decided once before anything is
+/// painted: the page; each pane's grip, tag, body, rail (with its thumb)
+/// and notice chips; each column's grip and tag; the workspace tag. The
+/// painters below and every shell read these rectangles.
+pub fn place(p: *Pardes, s: *Surface) void {
+ s.nregions = 0;
+ s.addRegion(.{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } });
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ Messages.collectNotices(p, pane, p.rects[id], id);
+ const r = p.rects[id];
+ if (r.w <= config.GUTTER or r.h == 0) continue;
+ const owner: u8 = @intCast(id);
+ const active = id == p.active;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } });
+ s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } });
+ // A folded pane is its tag rows and nothing else.
+ if (pane.collapsed) continue;
+ const body_h = r.h -| pane.tag_rows;
+ s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } });
+ var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } };
+ // An image's rail has no thumb; a native PDF's is measured as its
+ // pages are placed (Pdf.draw); every other pane's is here.
+ if (pane.image == null and body_h > 0) {
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
+ .total = panes.File.nlines(p.gpa, f),
+ .offset = f.scroll,
+ .len = pane.rows,
+ } else if (pane.pdfPage()) |page| .{
+ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1,
+ .offset = page,
+ .len = 1,
+ } else blk: {
+ const gsb = panes.Terminal.scrollbar(pane);
+ break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
+ };
+ const track_h: usize = body_h;
+ const total = if (sb.total == 0) 1 else sb.total;
+ const len = @max(1, (track_h * @max(1, sb.len)) / total);
+ const pos = (track_h * sb.offset) / total;
+ rail.thumb_y = @intCast(@min(pos, track_h));
+ rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y));
+ }
+ s.addRegion(rail);
+ // Each notice a chip at the right edge, one to a row in the order
+ // collectNotices chose, as wide as its own message and no wider, so
+ // the text beneath the rest of the row keeps reading as the file.
+ if (r.h <= pane.tag_rows) continue;
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ var leader_buf: [16]u8 = undefined;
+ for (0..pane.notices.len) |i| {
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ const chip = Messages.noticeCols(p, text, tw);
+ if (chip == 0) continue;
+ pane.notices.left[i] = tx + tw - chip;
+ s.addRegion(.{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } });
+ }
+ }
+ const column_rows = p.columnBarHeight();
+ if (column_rows > 0) for (0..p.ncol) |c| {
+ if (p.col_w[c] == 0) continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == c else false;
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ s.addRegion(.{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } });
+ s.addRegion(.{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } });
+ };
+ const active_col = if (layout.findPane(p, p.active)) |found| found.col else null;
+ for (0..p.ncol) |c| {
+ if (p.col_w[c] == 0) continue;
+ s.addRegion(.{ .kind = .column, .owner = @intCast(c), .serial = p.col_serial[c], .active = active_col == c, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = p.col_w[c], .h = s.rows -| p.topBarHeight() } });
+ }
+ if (p.settings.workspace_tag)
+ s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } });
+ // A column carried to the window's right edge shows where it lands as a
+ // rail in the last cell: the layers under it stop a cell short.
+ if (p.drag == .column_move) rail: {
+ const d = p.drag.column_move;
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ if (edge != s.cols) break :rail;
+ for (s.regions[0..s.nregions]) |*region| switch (region.kind) {
+ .body, .tag, .notice, .column_tag => if (region.rect.x + region.rect.w == s.cols) {
+ region.clip = 1;
+ },
+ else => {},
+ };
+ }
+}
+
+pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ panes.File.refreshHighlights(p);
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ const cells = try p.gpa.alloc(Cell, ncells);
+ p.gpa.free(s.cells);
+ s.cells = cells;
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.pointer_shape = .arrow;
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
+ if (preview.probe.link != null and preview.pane < p.panes.len) {
+ if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
+ s.pointer_shape = .link;
+ }
+ }
+ };
+ // A look-hover preview means the pointed word is a real Look or Exec
+ // operand: the same plumbing a click would act on. Hosts that model
+ // the pointer per-text (the macOS I-beam) read this as "target" and
+ // afford it the arrow instead.
+ if (p.look_hover_preview != null) s.pointer_shape = .target;
+ for (&s.body_layers) |*layer| layer.rows = 0;
+ for (&s.tag_layers) |*layer| layer.rows = 0;
+ s.nimages = 0;
+ s.npanel_tracks = 0;
+ s.previous_cells = &.{};
+ s.previous_body_layers = &.{};
+ s.previous_tag_layers = &.{};
+ s.previous_regions = &.{};
+ s.cell_diffs = &.{};
+ const chrome = p.chromeTheme();
+ s.chrome = .{
+ .page = p.theme().bg,
+ .fg = p.theme().fg,
+ .border = chrome.border,
+ .topbar_rule = config.gui_topbar_pane_border_rgb orelse chrome.border,
+ .tag_bg = chrome.tag_bg,
+ .tag_focus_bg = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg,
+ .tag_fg = chrome.tag_fg,
+ .scroll_track = chrome.scroll_track,
+ .scroll_thumb = chrome.scroll_thumb,
+ };
+ place(p, s);
+ // Separators have their own role; a scrollbar need not frame the page.
+ const whole = s.regions[0].rect;
+ s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ s.body_layers[id].rows = 0;
+ try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active);
+ }
+ // Each notice once, as a tag layer, and the grid's chip a copy of it.
+ for (s.regionList()) |region| {
+ if (region.kind == .notice) try joinNotice(p, s, arena, region);
+ }
+
+ try paintHeaders(p, s, arena);
+
+ switch (p.drag) {
+ .border_v => |d| {
+ if (d.cur_x < s.cols) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } });
+ }
+ if (d.corner) |k| if (d.cur_y < s.rows) {
+ const first = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
+ const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
+ var col = first;
+ while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } });
+ };
+ },
+ // A column that went away mid-drag has nothing to guide.
+ .border_h => |d| if (d.cur_y < s.rows and d.col < p.ncol and p.col_x[d.col] < s.cols) {
+ const end = @min(s.cols, p.col_x[d.col] + p.col_w[d.col]);
+ var col = p.col_x[d.col];
+ while (col < end) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = end - p.col_x[d.col], .h = 1 } });
+ },
+ .column_move => |d| rail: {
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ if (s.cols == 0) break :rail;
+ // Where the column's left edge would land, reordered or not.
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ // The layers under an outside rail stop a cell short of it:
+ // `place` clipped them.
+ // The rail runs in the seam cell left of the edge, where a
+ // border drag's does: the edge cell itself is a gutter, which
+ // the GUI paints as a scroll rail that swallowed the dashes.
+ // At the window's left edge there is no seam, so it runs just
+ // past that scroll rail instead. It takes the lit grip's
+ // color: this is where the grabbed column lands.
+ const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
+ var row: u16 = p.topBarHeight();
+ // A guide region for each run of it the tags do not break.
+ var run: u16 = row;
+ while (row < s.rows) : (row += 1) {
+ // Past the left edge's scroll rail the rail would cut
+ // through column 0's grip and pane boxes: leave them whole.
+ if (edge == 0 and s.at(x, row).style.font_role == .tagline) {
+ if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } });
+ run = row + 1;
+ continue;
+ }
+ s.overlayDash(x, row, "╎");
+ s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
+ }
+ if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } });
+ },
+ .move => |d| if (d.cur_x < s.cols) {
+ if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| if (placement.row < s.rows and p.col_x[placement.preview_col] + p.col_w[placement.preview_col] <= s.cols) {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } });
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } });
+ };
+ },
+ // A hover left over from a bigger window points past this one.
+ .none, .select => if (p.pointer_inside and p.hover_row < s.rows and p.hover_col < s.cols) {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } });
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ // the same seam the h-handle hit test picks (see there)
+ if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } });
+ }
+ }
+ for (0..p.ncol -| 1) |cn| {
+ if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
+ if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
+ if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
+ var col = p.col_x[cn + 1];
+ while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } });
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.settings.debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |*f| {
+ sb_off = f.scroll;
+ sb_total = panes.File.nlines(p.gpa, f);
+ } else if (at.pdfPage()) |page| {
+ sb_off = page;
+ sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
+ } else {
+ const sb = panes.Terminal.scrollbar(at);
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ const effective_font = p.settings.font.effective_name.get();
+ const font_name = if (effective_font.len == 0) "default" else effective_font;
+ var ov_buf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ // wide enough for the longest theme name compiled in, which is the
+ // only row here whose width is not ours to choose
+ const ow: u16 = 38;
+ const oh: u16 = 10;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
+ s.addRegion(.{ .kind = .debug, .rect = .{ .x = ox, .y = p.topBarHeight(), .w = ow, .h = oh } });
+ // vaxis single_rounded border ring, default style
+ s.set(ox, p.topBarHeight(), "╭", .{});
+ s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
+ s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, p.topBarHeight(), "─", .{});
+ s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
+ }
+ var by = p.topBarHeight() + 1;
+ while (by < p.topBarHeight() + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = p.topBarHeight() + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= p.topBarHeight() + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
+ }
+ }
+ };
+ try p.presentation.submit(p, s);
+ return composeAsciiTransitions(p, arena, s);
+}
+
+pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
+ _ = p;
+ if (!canonical.hasPanelDiff()) return canonical;
+ var presented: ?*Surface = null;
+ for (canonical.panelTracks()) |track| {
+ if (!track.effect.composedByCore() or track.phase == .closing) continue;
+ const area = animation.CellArea.of(track.to);
+ const col_end = @min(canonical.cols, area.x0 +| area.cols);
+ const row_end = @min(canonical.rows, area.y0 +| area.rows);
+ var row: u16 = area.y0;
+ while (row < row_end) : (row += 1) {
+ var col: u16 = area.x0;
+ while (col < col_end) : (col += 1) {
+ const index = @as(usize, row) * canonical.cols + col;
+ const composed = composedCell(canonical, track, area, col, row, index) orelse
+ continue;
+ if (composed.visuallyEqual(&canonical.cells[index])) continue;
+ if (presented == null) {
+ const next = try arena.create(Surface);
+ next.* = canonical.*;
+ next.cells = try arena.dupe(Cell, canonical.cells);
+ presented = next;
+ }
+ presented.?.cells[index] = composed;
+ }
+ }
+ }
+ return presented orelse canonical;
+}
+
+fn composedCell(
+ canonical: *const Surface,
+ track: animation.Track,
+ area: animation.CellArea,
+ col: u16,
+ row: u16,
+ index: usize,
+) ?Cell {
+ if (track.effect == .ascii) {
+ const diff = switch (canonical.cell_diffs[index]) {
+ .ascii => |diff| diff,
+ .unchanged, .visual => return null,
+ };
+ const byte = diff.byteAt(track.frame);
+ if (byte == diff.to) return null;
+ if (track.frame == 0) return canonical.previous_cells[index];
+ return withByte(canonical.cells[index], byte);
+ }
+ return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) {
+ .old => canonical.previous_cells[index],
+ .byte => |byte| withByte(canonical.cells[index], byte),
+ // Churn belongs on the glyph, not on the pane's empty space, and it
+ // borrows whichever side of the diff actually has one.
+ .churn => |byte| blk: {
+ const new_cell = &canonical.cells[index];
+ if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
+ const old_cell = &canonical.previous_cells[index];
+ if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
+ break :blk old_cell.*;
+ },
+ .at => |offset| blk: {
+ const source_col = @as(i32, col) + offset.cols;
+ const source_row = @as(i32, row) + offset.rows;
+ // A glyph whose source lies outside the pane has not arrived
+ // yet: the frozen old cell is the only truthful thing under it.
+ if (source_col < area.x0 or source_row < area.y0 or
+ source_col >= @as(i32, area.x0) + area.cols or
+ source_row >= @as(i32, area.y0) + area.rows or
+ source_col >= canonical.cols or source_row >= canonical.rows)
+ break :blk canonical.previous_cells[index];
+ const source = @as(usize, @intCast(source_row)) * canonical.cols +
+ @as(usize, @intCast(source_col));
+ break :blk canonical.cells[source];
+ },
+ };
+}
+
+fn withByte(cell: Cell, byte: u8) Cell {
+ var out = cell;
+ out.text[0] = byte;
+ out.len = 1;
+ out.default = false;
+ return out;
+}
+
+/// Whether a cell shows a glyph a character effect can work on. An unpainted
+/// cell and a space are both blank space, whatever style they carry.
+fn hasGlyph(cell: *const Cell) bool {
+ return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
+}
+
+fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const tz = tracy.zone(@src(), "renderPane");
+ defer tz.end();
+ if (r.w <= config.GUTTER or r.h == 0) return;
+ const th = p.theme();
+ 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 = 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);
+ // light themes paint the page over it.
+ {
+ // Two full passes over every cell in the pane, every frame.
+ const tz_clear = tracy.zone(@src(), "paneClear");
+ defer tz_clear.end();
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+ }
+
+ const box_bg = if (active) chrome.box else chrome.box_dim;
+ const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
+ const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+ const box_style: CellStyle = .{
+ .fg = .{ .rgb = box_ink },
+ .bg = .{ .rgb = box_bg },
+ // Grips keep physical grid dimensions, independently of tag text.
+ .font_role = .tagline,
+ };
+ 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,
+ .tty => config.tag_tty,
+ }, box_style);
+
+ try joinPaneTag(p, s, arena, pane, r, id, active);
+
+ // Folded panes retain their body state, but neither body pixels nor
+ // its cursor belong outside the one-row tag rectangle.
+ if (pane.collapsed) return;
+
+ const rail = s.region(.rail, id).?;
+ if (comptime pdf_enabled)
+ if (pane.hasPdf() and panes.Pdf.draw(p, s, pane, r, id, tx, tw, rail)) return;
+
+ if (pane.image) |*iv| {
+ // Below the notice chips: a picture is drawn after the cells (the
+ // 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 -| pane.tag_rows) -| shown;
+ panes.Image.draw(p, s, 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
+ s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg });
+ return;
+ }
+
+ try body_layer.joinBody(p, s, arena, pane, r, id, active, rail.*);
+}
+
+/// The workspace and column tags: each line a tag layer, and the grid's
+/// rows copies of those; the column grips, which are the grid's alone.
+pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void {
+ const chrome = p.chromeTheme();
+ // 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
+ tagline.revealHeader(p);
+ if (s.region(.workspace_tag, Region.none)) |region| {
+ const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
+ const layer = &s.tag_layers[MAX_PANES + MAX_COLS];
+ try renderHeaderLayer(p, arena, layer, .workspace, 0, region.rect, tagline.topbar(p), top, chrome.tag_bg, chrome.tag_fg);
+ joinHeaderRows(s, layer, chrome.tag_fg);
+ }
+ const column_rows = p.columnBarHeight();
+ if (column_rows > 0) for (0..p.ncol) |c| {
+ const region = s.region(.column_tag, c) orelse continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == c and p.settings.focus_tint else false;
+ const bg = if (active) chrome.tag_active_bg else chrome.tag_bg;
+ const fg = if (active) chrome.tag_active_fg else chrome.tag_fg;
+ if (p.col_x[c] < s.cols) {
+ // A grip is muted until it is grabbed; then it lights, as a
+ // pane's box does for the active pane, so the drag shows what it
+ // holds.
+ const grip = @min(config.GUTTER, p.col_w[c]);
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
+ const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
+ 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 };
+ s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ }
+ const top = if (p.header_focus and p.header_column == c) p.header_top else 0;
+ const layer = &s.tag_layers[MAX_PANES + c];
+ try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg);
+ if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg);
+ // Laid out at its full width; only shown short of the rail.
+ layer.viewport.w -|= region.clip;
+ };
+}
+
+/// Copy a header's layer into the grid under it, row by row, and its caret.
+fn joinHeaderRows(s: *Surface, layer: *const pardes.Layer, fg: [3]u8) void {
+ const v = layer.viewport;
+ const w = @min(v.w, s.cols -| v.x);
+ for (0..layer.rows) |line| joinCells(s, v.x, v.y + @as(u16, @intCast(line)), layer.cells[line * layer.cols ..][0..layer.cols], 0, w, .{ .rgb = fg });
+ if (layer.cursor) |caret| {
+ if (caret.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.y, .bar = caret.bar };
+ }
+}
+
+/// Copy `w` cells of a layer row, from `from`, into the grid at (x, y) --
+/// the one place the grid gets a tag's, a header's or a notice's cells. A
+/// wide glyph cut by either end is left as the band's blank, the way the
+/// grid's own print never drew half of one: no head without its spacer,
+/// no spacer without its head. `ink` is the band's text colour, which a
+/// blank cell does not carry.
+fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u16, ink: Color) void {
+ // A tagline wider than the body's cells can lay out fewer than `w`.
+ const n: u16 = @intCast(@min(w, cells.len -| from));
+ if (n == 0 or y >= s.rows) return;
+ const row = s.cells[@as(usize, y) * s.cols ..][0..s.cols];
+ @memcpy(row[x..][0..n], cells[from..][0..n]);
+ const blank = struct {
+ fn of(cell: *Cell, band_ink: Color) void {
+ cell.text[0] = ' ';
+ cell.len = 1;
+ if (std.meta.eql(cell.style.fg, band_ink)) cell.style.fg = .default;
+ }
+ }.of;
+ const last = from + n - 1;
+ if (last + 1 < cells.len and !cells[last + 1].default and cells[last + 1].len == 0) blank(&row[x + n - 1], ink);
+ if (!cells[from].default and cells[from].len == 0) blank(&row[x], ink);
+}
+
+fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
+ if (pane.isTerminal()) {
+ // `tag` is the first row; a prefix longer than it ran to a second.
+ var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n");
+ while (words.next()) |word| {
+ if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(u8, word, "Tty+")) continue;
+ const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
+ return .{ .lo = lo, .hi = lo + word.len };
+ }
+ return null;
+ }
+ var start: usize = 0;
+ const name = if (pane.file) |file|
+ if (pane.prompt == .name) pane.input.own orelse file.path else file.path
+ else if (pane.image) |state| blk: {
+ // Image tags put renderer controls before the path.
+ start = std.mem.lastIndexOf(u8, tag[0..@min(prefix_len, tag.len)], state.path) orelse return null;
+ break :blk state.path;
+ } else if (comptime pdf_enabled) blk: {
+ break :blk if (pane.pdf) |state| state.path else return null;
+ } else return null;
+ const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0;
+ if (basename == name.len) return null;
+ return .{ .lo = start + basename, .hi = start + name.len };
+}
+
+/// A header or a notice band as a tag layer: `viewport.h` rows, line `top`
+/// of `text` and on, laid out at the tagline's pitch -- as wide as its
+/// widest line and never narrower than its viewport.
+fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void {
+ _ = arena;
+ if (viewport.w == 0 or viewport.h == 0) {
+ // No layer, and nothing of an earlier frame's left in it.
+ layer.rows = 0;
+ layer.viewport = viewport;
+ layer.cursor = null;
+ return;
+ }
+ var widest: usize = 0;
+ for (0..viewport.h) |line| widest = @max(widest, panes.File.displayWidth(modal.lineSlice(text, line + top)));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), widest + 1)));
+ const count = @as(usize, columns) * viewport.h;
+ if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
+ layer.kind = kind;
+ layer.id = id;
+ layer.serial = if (kind == .column) p.col_serial[id] else 0;
+ layer.viewport = viewport;
+ layer.cols = columns;
+ layer.rows = viewport.h;
+ layer.context_rows = 0;
+ layer.bg = bg;
+ layer.cursor = null;
+ layer.slide = 0;
+ layer.fade = 1;
+ // A notice band carries a PANE id, not a column index, so it must not
+ // answer to the column being edited -- that would scroll and select
+ // its text with the column tag's offsets and give it a second caret.
+ const editing: ?*Text = if (p.header_focus and switch (kind) {
+ .workspace => p.header_column == null,
+ .column => p.header_column == id,
+ .pane, .notice, .body => false,
+ }) tagline.focusedHeader(p) else null;
+ const scroll = if (editing != null) p.header_scroll else 0;
+ for (0..viewport.h) |index| {
+ const line: u16 = @intCast(index);
+ const shown = modal.lineSlice(text, index + top);
+ var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] };
+ surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ // The scroll is the caret's line's: a shorter line shows nothing.
+ _ = surface.print(0, 0, columns, shown[@min(shown.len, panes.File.rawAtDisplay(shown, scroll))..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ // The word under the pointer, a Look or Exec target: on a header even
+ // while it is edited, on a notice, and on the grid's copy of either
+ // (joined from this); not on a column a carried column's pointer is
+ // aiming at a place over.
+ const hovered = p.pointer_inside and !(kind == .column and p.drag == .column_move);
+ if (hovered) {
+ const tag_hit = p.pointer_tag_hit;
+ const col: ?usize = if (tag_hit) |pointer|
+ (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
+ else if (p.hover_row == viewport.y + line and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w)
+ p.hover_col - viewport.x
+ else
+ null;
+ if (col) |hover| if (wordBoundsAtCol(shown, panes.File.rawAtDisplay(shown, hover + scroll))) |bounds| {
+ var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll;
+ while (cell < end and cell < columns) : (cell += 1) {
+ surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ };
+ }
+ if (editing) |t| {
+ if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
+ var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll;
+ while (col < end and col < columns) : (col += 1) {
+ surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ }
+ const caret = panes.File.rawDisplayCol(shown, @min(@as(usize, @intCast(@max(0, t.cur_col))), shown.len)) -| scroll;
+ const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
+ if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .x = @intCast(caret), .y = line, .bar = t.mode == .insert };
+ }
+ }
+}
+
+/// A pane's tag rows, painted once into their layer, a row to a line, and
+/// copied into the grid; the grid's caret where the layer has it.
+fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET);
+ // Too narrow for a layer: only the grid's gap cell.
+ if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity);
+ const chrome = p.chromeTheme();
+ const region = s.region(.tag, id).?;
+ const tag = try tagline.tagText(p, arena, pane);
+ 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..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(capacity, 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 = rows, .cells = storage };
+ try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity);
+ const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg };
+ // One layer, a row to each line the tag shows.
+ const layer = &s.tag_layers[id];
+ const count = @as(usize, columns) * rows;
+ if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
+ layer.kind = .pane;
+ layer.id = @intCast(id);
+ layer.serial = pane.serial;
+ layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y, .w = region.rect.w - TAG_GAP - region.clip, .h = rows };
+ layer.cols = columns;
+ layer.rows = rows;
+ layer.context_rows = 0;
+ layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ layer.slide = 0;
+ layer.fade = 1;
+ layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
+ .{ .x = cursor.x - TAG_TEXT_INSET, .y = cursor.y, .bar = cursor.bar }
+ else
+ null;
+ for (0..rows) |line| {
+ @memcpy(layer.cells[line * columns ..][0..columns], storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
+ // The grid's row: the gap cell and the text, as far as the pane.
+ joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg);
+ }
+ if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) {
+ s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar };
+ };
+}
+
+/// A notice chip, painted once as a tag layer at the tagline's pitch, and the
+/// grid's chip a copy of its right end, where its text is.
+fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) !void {
+ const chrome = p.chromeTheme();
+ const th = p.theme();
+ const id: usize = region.owner;
+ const pane = p.panes[id].?;
+ const i: usize = region.notice;
+ const kind = pane.notices.kinds[i];
+ const r = p.rects[id];
+ const chip = region.rect.w;
+ var leader_buf: [16]u8 = undefined;
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ // A message coming or going blends toward the page it sits on; a grid
+ // cannot move it by less than a row, a pixel shell slides it too.
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
+ const page = th.bg orelse chrome.tag_bg;
+ const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha);
+ // Where an exec was sent to a REPL reads in the tag's name tint, set
+ // apart from what the editor says.
+ const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg;
+ const fg = Messages.blendRgb(ink, page, motion.alpha);
+ // Nothing to say, or too narrow for a layer: a blank chip on the grid.
+ if (text.len == 0 or r.w <= TAG_TEXT_INSET) {
+ s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (text.len == 0) return;
+ const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) return;
+ const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ if (prompt_col < printed.dropped) return;
+ s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert };
+ return;
+ }
+ // Right aligned inside the chip, a blank cell short of its edge: a
+ // prompt's caret sits AFTER its last character the moment it opens, and
+ // flush right would put it past the pane and off the grid. Wider than
+ // the chip, the text loses its head: the end is the part that says
+ // something.
+ const capacity = p.tagCapacity(chip);
+ const room = capacity -| 1;
+ const shown = panes.File.displayWidth(text);
+ var kept = text;
+ if (shown > room) {
+ var start = panes.File.rawAtDisplay(text, shown - room);
+ if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
+ kept = text[start..];
+ }
+ const pad = room -| panes.File.displayWidth(kept);
+ const line = try arena.alloc(u8, pad + kept.len);
+ @memset(line[0..pad], ' ');
+ @memcpy(line[pad..], kept);
+ const layer = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i];
+ try renderHeaderLayer(p, arena, layer, .notice, @intCast(id), region.rect, line, 0, bg, fg);
+ layer.slide = motion.slide;
+ layer.fade = motion.alpha;
+ // The caret rides the band, where the prompt's glyphs are.
+ if (kind == .prompt and id == p.active and pane.prompt != .del_side and pane.prompt != .repl_choice) caret: {
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ const dropped = shown - panes.File.displayWidth(kept);
+ if (typed < dropped) break :caret;
+ layer.cursor = .{ .x = @intCast(pad + typed - dropped), .y = 0, .bar = pane.input.mode == .insert };
+ }
+ // The grid shows the chip's last cells of the band, where the text ends
+ // (all of it where the tagline's pitch is the grid's).
+ const skip = capacity -| chip;
+ joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg });
+ // The band's padding before the text is the chip's fill, which carries
+ // no ink of its own (the layer prints it as spaces in the band's ink).
+ // A notice wider than its band has less padding than the band hides.
+ for (skip..@max(skip, @min(pad, skip + chip))) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default;
+ if (layer.cursor) |caret| {
+ if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar };
+ }
+ // Laid out at its full width; only shown short of the rail.
+ layer.viewport.w -|= region.clip;
+}
+
+pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + @min(TAG_TEXT_INSET, r.w);
+ const tw = r.w -| TAG_TEXT_INSET;
+ 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;
+ const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
+ 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), 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 tagline.tagText(p, arena, pane);
+ const tail = tagline.curTail(pane);
+ const prefix_len = whole.len - tail.len;
+ // 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
+ // path while a name is drafted.
+ const Caret = struct { row: usize, at: usize };
+ const caret: ?Caret = if (!active)
+ 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.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
+ const row: usize = @intCast(pane.tag.cur_row - top);
+ break :caret .{ .row = row, .at = @min(lines[row].len, @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 (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);
+ }
+ // A line past a u16 of columns keeps its caret off the band.
+ pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16)));
+ } else if (pane.focus != .tag) pane.tag_scroll = 0;
+ for (lines[0..rows], 0..) |line, row| {
+ // The scroll is the caret's line's: a shorter row shows nothing.
+ const start = @min(line.len, 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 (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) {
+ var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
+ const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ 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));
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
+ };
+ }
+ // 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(whole, 0, &ranges);
+ for (ranges[0..got.n]) |range| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (modal.nextGrapheme(whole, lo) >= hi) continue;
+ const first = modal.positionAt(whole, lo);
+ const last = modal.positionAt(whole, hi);
+ var row = @max(first.row, top);
+ while (row <= last.row and row < top + rows) : (row += 1) {
+ const line = lines[row - top];
+ const from = if (row == first.row) first.col else 0;
+ const to = @min(line.len, if (row == last.row) last.col else line.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 - 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;
+ 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| {
+ 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 + @as(u16, @intCast(at.row)), .bar = mode == .insert };
+ }
+
+ // Pointer sweeps over the tag rows. A select sweep is the tag's own, in
+ // its selection colours; the look-hover affordance goes over it, and a
+ // middle or right sweep over both, tinted by its button the way the
+ // body tints it.
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_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));
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
+ };
+ }
+ const sel_btn = body_layer.buttonTints(th);
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg);
+ }
+}
+
+test "place puts every part where the painters and layers put it, in every layout" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ _ = try p.setTestFile("placed\n" ** 80);
+ p.newScratchColumn(0);
+ p.newScratchBelow(0);
+ p.sync();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for (0..16) |step| {
+ p.settings.tag_bottom = step & 1 != 0;
+ p.settings.column_tags = step & 2 != 0;
+ if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice"));
+ if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
+ if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
+ if (step == 10) {
+ const pane = p.panes[p.active].?;
+ pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird");
+ }
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ try std.testing.expectEqual(Region.Kind.page, s.regions[0].kind);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const r = p.rects[id];
+ if (r.w <= config.GUTTER or r.h == 0) continue;
+ const grip = s.region(.grip, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x, .y = p.tagTop(pane, r), .w = config.GUTTER, .h = pane.tag_rows }, grip.rect);
+ // The grid's grip cell is the grip's.
+ try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role);
+ const tag = s.region(.tag, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect);
+ // One layer, a row to each line of the tag.
+ if (r.w > TAG_TEXT_INSET) {
+ try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r), .w = r.w - TAG_TEXT_INSET, .h = pane.tag_rows }, s.tag_layers[id].viewport);
+ try std.testing.expectEqual(pane.tag_rows, s.tag_layers[id].rows);
+ }
+ if (pane.collapsed) {
+ try std.testing.expect(s.region(.body, id) == null);
+ try std.testing.expect(s.region(.rail, id) == null);
+ continue;
+ }
+ const body = s.region(.body, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = r.w - config.GUTTER, .h = r.h - pane.tag_rows }, body.rect);
+ const rail = s.region(.rail, id).?;
+ // The painted thumb is exactly the placed one.
+ for (0..rail.rect.h) |row| {
+ const y = rail.rect.y + @as(u16, @intCast(row));
+ const thumb = row >= rail.thumb_y and row < rail.thumb_y + rail.thumb_h;
+ const want = if (thumb) p.chromeTheme().scroll_thumb else p.chromeTheme().scroll_track;
+ try std.testing.expectEqual(Color{ .rgb = want }, s.at(rail.rect.x, y).style.bg);
+ }
+ var notices: usize = 0;
+ for (s.regionList()) |region| {
+ if (region.kind != .notice or region.owner != id) continue;
+ notices += 1;
+ try std.testing.expectEqual(pane.notices.left[region.notice], region.rect.x);
+ try std.testing.expectEqual(r.x + r.w, region.rect.x + region.rect.w);
+ try std.testing.expectEqual(p.bodyTop(pane, r) + pane.notices.row[region.notice], region.rect.y);
+ try std.testing.expectEqual(region.rect, s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + region.notice].viewport);
+ }
+ try std.testing.expectEqual(@as(usize, pane.notices.len), notices);
+ }
+ for (0..p.ncol) |c| {
+ const region = s.region(.column_tag, c);
+ if (p.columnBarHeight() == 0) {
+ try std.testing.expect(region == null);
+ continue;
+ }
+ try std.testing.expectEqual(Rect{ .x = p.col_x[c] + TAG_TEXT_INSET, .y = p.topBarHeight(), .w = p.col_w[c] - TAG_TEXT_INSET, .h = p.columnBarHeight() }, region.?.rect);
+ }
+ }
+}
+
+test "the word under the pointer on a notice is lit in its layer and in the grid's copy" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg Look here"));
+ p.sync();
+ var s = try p.render(arena.allocator());
+ const notice = for (s.regionList()) |region| {
+ if (region.kind == .notice) break region;
+ } else return error.TestExpectedNotice;
+ const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols];
+ // "here", the chip's last word, before its blank edge.
+ const here = notice.rect.x + notice.rect.w - 5;
+ try std.testing.expectEqualStrings("h", row[here].grapheme());
+ p.pointer_inside = true;
+ p.hover_col = here + 1;
+ p.hover_row = notice.rect.y;
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ const lit = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols];
+ const sel: Color = .{ .rgb = p.theme().sel_bg };
+ for (lit[here..][0..4]) |cell| try std.testing.expectEqual(sel, cell.style.bg);
+ try std.testing.expect(!std.meta.eql(sel, lit[here - 2].style.bg));
+ const layer = for (s.tagLayers()) |*layer| {
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
+ } else return error.TestExpectedNotice;
+ const lit_in_layer = for (layer.cells) |cell| {
+ if (std.meta.eql(sel, cell.style.bg)) break true;
+ } else false;
+ try std.testing.expect(lit_in_layer);
+}
+
+test "a notice wider than its band, as a shader's compile error is, draws its end and nothing past it" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20 });
+ defer p.deinit();
+ // A gui-shaped core: its tagline is narrower than the body, so a notice's
+ // band holds more columns than its chip has cells.
+ p.update(.{ .resize = .{ .cols = 80, .rows = 20, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ _ = try p.setTestFile("text\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const long = "Shader /home/someone/shaders/test_shadertoy_invalid.glsl: error: Linking fragment stage: No function definition (body) found: " ** 3;
+ for ([_][]const u8{ "Msg " ++ long, "" }) |line| {
+ // The Msg builtin, and the path a failed compile takes (Post.zig).
+ if (line.len > 0) try std.testing.expect(p.executeBuiltinLine(p.active, line)) else p.setMessage(p.active, long);
+ p.sync();
+ for (0..30) |_| p.update(.tick);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const notice = for (s.regionList()) |region| {
+ if (region.kind == .notice) break region;
+ } else return error.TestExpectedNotice;
+ // Text up to the chip's blank last cell, and no padding ink.
+ const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][notice.rect.x..][0..notice.rect.w];
+ try std.testing.expectEqualStrings(" ", row[row.len - 1].grapheme());
+ try std.testing.expect(!std.mem.eql(u8, " ", row[row.len - 2].grapheme()));
+ }
+}
+
+test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
+ // The snapshot harness runs the tty, whose tagline pitch is the body's,
+ // so it never reaches what a narrower tagline does to a notice's band:
+ // this drives the core at a gui's metrics instead. PARDES_FUZZ_STEPS
+ // runs it longer (the suite's run is short).
+ const steps: usize = if (std.c.getenv("PARDES_FUZZ_STEPS")) |v| std.fmt.parseInt(usize, std.mem.span(v), 10) catch 400 else 400;
+ const seed: u64 = if (std.c.getenv("PARDES_FUZZ_SEED")) |v| std.fmt.parseInt(u64, std.mem.span(v), 10) catch 7 else 7;
+ var prng = std.Random.DefaultPrng.init(seed);
+ const random = prng.random();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("fuzz\n\u{754c}\u{754c} wide\n" ** 20);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " };
+ const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
+ var text: std.ArrayListUnmanaged(u8) = .empty;
+ defer text.deinit(std.testing.allocator);
+ for (0..steps) |_| {
+ switch (random.uintLessThan(u8, 8)) {
+ // A resize, down to a cell, at a gui's metrics: a tagline no
+ // wider and no taller than the body's cell, often narrower.
+ 0 => {
+ const body_w = random.intRangeAtMost(u16, 6, 12);
+ const body_h = random.intRangeAtMost(u16, 12, 24);
+ const cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 12) else random.intRangeAtMost(u16, 13, 140);
+ const rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 50);
+ p.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = body_w, .body_h = body_h, .tagline_w = random.intRangeAtMost(u16, 2, body_w), .tagline_h = random.intRangeAtMost(u16, 6, body_h) } } });
+ },
+ // A message, as long as a compile error or longer.
+ 1, 2 => {
+ text.clearRetainingCapacity();
+ for (0..random.uintAtMost(usize, 60)) |_| try text.appendSlice(std.testing.allocator, words[random.uintLessThan(usize, words.len)]);
+ if (random.boolean()) p.setMessage(p.active, text.items) else {
+ const line = try std.fmt.allocPrint(arena.allocator(), "Msg {s}", .{text.items});
+ _ = p.executeBuiltinLine(p.active, line);
+ }
+ },
+ 3 => _ = p.executeBuiltinLine(p.active, commands[random.uintLessThan(usize, commands.len)]),
+ // Typing, escaping and entering, into a prompt when one is up.
+ 4 => p.update(.{ .key = .{ .cp = switch (random.uintLessThan(u8, 9)) {
+ 0 => pardes.Key.escape,
+ 1 => pardes.Key.enter,
+ 2 => 0x754c,
+ 3 => pardes.Key.end,
+ 4 => pardes.Key.home,
+ 5 => pardes.Key.up,
+ 6 => pardes.Key.down,
+ else => 'a' + random.uintLessThan(u21, 26),
+ } } }),
+ // A tag of several rows.
+ 5 => if (p.panes[p.active]) |pane| {
+ text.clearRetainingCapacity();
+ for (0..random.uintAtMost(usize, 5)) |_| try text.appendSlice(std.testing.allocator, if (random.boolean()) " Look\nmore \u{754c}" else "\n" ++ "x" ** 150);
+ if (pane.tag.own) |own| std.testing.allocator.free(own);
+ pane.tag.own = try std.testing.allocator.dupe(u8, text.items);
+ },
+ // Hover, and clicks: into a tag, a header, a body, a grip.
+ 6 => {
+ const col = random.uintAtMost(u16, p.screen_w);
+ const row = random.uintAtMost(u16, p.screen_h);
+ if (random.boolean()) p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }) else {
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col, .row = row } });
+ }
+ },
+ else => for (0..random.uintAtMost(usize, 20)) |_| p.update(.tick),
+ }
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ p.acknowledgePanelPresentation(s.panelTracks());
+ }
+}
+
+test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 });
+ defer p.deinit();
+ _ = try p.setTestFile("wide\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const pane = p.panes[p.active].?;
+ // Both alignments, so one of them puts a wide glyph across each edge.
+ for ([_][]const u8{ "\u{754c}" ** 30, "x" ++ "\u{754c}" ** 30 }) |wide| {
+ if (pane.tag.own) |own| std.testing.allocator.free(own);
+ pane.tag.own = try std.testing.allocator.dupe(u8, wide);
+ var message: [200]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&message, "Msg {s}", .{wide})));
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ for (s.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .notice) continue;
+ const row = s.cells[@as(usize, region.rect.y) * s.cols ..][region.rect.x..][0..region.rect.w];
+ for (row, 0..) |cell, i| {
+ const spacer = !cell.default and cell.len == 0;
+ const head = !cell.default and cell.len > 1 and i + 1 < row.len and !row[i + 1].default and row[i + 1].len == 0;
+ // A spacer has its head before it; a head its spacer after.
+ if (spacer) try std.testing.expect(i > 0 and row[i - 1].len > 1);
+ if (i + 1 == row.len) try std.testing.expect(!head and !(cell.len > 1 and vaxis.gwidth.gwidth(cell.grapheme(), .unicode) == 2));
+ }
+ // The right edge of each is the band, blank where a glyph was cut.
+ try std.testing.expectEqual(FontRole.tagline, row[row.len - 1].style.font_role);
+ }
+ }
+}
+
+test "a body with context rows is painted once, and the grid's rows are what a plain paint of it gives" {
+ if (comptime !@import("syntax.zig").enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 });
+ defer p.deinit();
+ const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body line\n" ** 30) ++ " }\n};\n");
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, "/test.zig");
+ if (!panes.File.supportsContext(pane)) return error.SkipZigTest;
+ pane.file.?.tree_context = true;
+ pane.file.?.scroll = 8;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for ([_]bool{ false, true }) |bottom| {
+ p.settings.tag_bottom = bottom;
+ // A selection and the cursor on body rows, painted in the one pass.
+ pane.body.cur_row = 12;
+ pane.body.cur_col = 3;
+ pane.body.vsel = .{ .active = true, .row = 10, .col = 2 };
+ pane.body.mode = .normal;
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const layer = &s.body_layers[0];
+ try std.testing.expect(layer.context_rows > 0);
+ const body = s.region(.body, 0).?;
+ // The plain paint the grid used to take, of the same frame.
+ const cells = try arena.allocator().alloc(Cell, s.cells.len);
+ @memset(cells, .{});
+ var plain: Surface = .{ .cols = s.cols, .rows = s.rows, .cells = cells };
+ const saved = .{ pane.body_rows, pane.body_visible_rows, pane.context_row_limit };
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ try body_layer.renderBody(p, &plain, arena.allocator(), pane, p.rects[0], 0, true, null, null);
+ pane.body_rows, pane.body_visible_rows, pane.context_row_limit = saved;
+ for (0..body.rect.h) |row| {
+ const y = body.rect.y + row;
+ for (0..body.rect.w) |col| {
+ const x = body.rect.x + col;
+ const got = s.cells[y * s.cols + x];
+ const want = plain.cells[y * s.cols + x];
+ try std.testing.expect(got.visuallyEqual(&want));
+ }
+ }
+ try std.testing.expectEqual(plain.cursor, s.cursor);
+ // The layer's own rows run past the grid's.
+ try std.testing.expect(layer.rows > body.rect.h);
+ }
+}