From 29e734cf91ffe5f4cca8b40a54aad91fff464a82 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:42:03 -0300 Subject: Place every part of the frame once, before painting it draw.place builds the Surface's region list at the head of render: the page; each pane's grip, tag rows, body, rail with its thumb, and notice chips; each column's grip and tag; the workspace tag. The painters read their geometry from it instead of working it out again: the grid's notice chips and the notice layers share one chip rectangle, the rails (file, terminal, image, PDF page) paint the placed thumb (a native PDF measures its own into the region as it places its pages), and the body, tag, notice and column layers take their viewports from their regions. A collapsed pane places only its grip and tag. The column-move rail's one-cell clip of the layers under it is now a region field set in place, not a mutation of the built layers. No behaviour change; a test checks every region against the old geometry, the painted rail and the layer viewports across tag bottom, column tags, notices, a collapsed pane and a multi-line tag. --- src/body_layer.zig | 37 ++------ src/draw.zig | 258 ++++++++++++++++++++++++++++++++++++++++++++--------- src/pardes.zig | 1 + src/pdf_view.zig | 25 ++---- src/surface.zig | 57 ++++++++++++ 5 files changed, 291 insertions(+), 87 deletions(-) (limited to 'src') diff --git a/src/body_layer.zig b/src/body_layer.zig index db4d9524..2b6ae773 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -279,7 +279,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; - try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, false); + try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, null); const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @@ -287,7 +287,8 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } layer.pane = @intCast(id); layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows }; + const body = p.surface.region(.body, id).?; + layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; @@ -299,7 +300,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } } -pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { +pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; @@ -501,34 +502,14 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane } } - if (scrollbar and body_h > 0) { + if (rail) |bar| if (bar.rect.h > 0) { // The rail covers the notice bands too: nothing else paints those // two gutter columns, so a gap there keeps the frame-wide border // fill and reads as a notch cut out of the scrollbar. - const rail_h = r.h -| pane.tag_rows; - s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg }); - const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ - .total = panes.File.nlines(p.gpa, f), - .offset = f.scroll, - .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; - var sy: usize = pos; - while (sy < track_h and sy < pos + len) : (sy += 1) { - s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); - } - } + s.fill(bar.rect.x, bar.rect.y, 1, bar.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(bar.rect.x + 1, bar.rect.y, 1, bar.rect.h, .{ .bg = pane_bg }); + s.fill(bar.rect.x, bar.rect.y + bar.thumb_y, 1, bar.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); + }; } pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { diff --git a/src/draw.zig b/src/draw.zig index d84f2033..e87e0511 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -18,6 +18,7 @@ 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; @@ -25,6 +26,7 @@ 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("tag_layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; @@ -40,6 +42,104 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_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; + const add = struct { + fn region(surface: *Surface, r: Region) void { + std.debug.assert(surface.nregions < surface.regions.len); + surface.regions[surface.nregions] = r; + surface.nregions += 1; + } + }.region; + add(s, .{ .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); + add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + add(s, .{ .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; + add(s, .{ .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)); + } + add(s, 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; + add(s, .{ .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]); + add(s, .{ .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 } }); + add(s, .{ .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 } }); + }; + if (p.settings.workspace_tag) + add(s, .{ .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; @@ -80,8 +180,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); + place(p, s); // Separators have their own role; a scrollbar need not frame the page. - s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); + 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; @@ -89,13 +191,15 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { pane.body_visible_rows = 0; pane.context_row_limit = null; s.body_layers[id].rows = 0; - Messages.collectNotices(p, pane, p.rects[id], id); try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } - for (&p.panes, 0..) |*slot, id| { - const pane = slot.* orelse continue; - if (pane.notices.len == 0) continue; + for (s.regionList()) |region| { + if (region.kind != .notice) continue; + 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 tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; @@ -108,25 +212,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }; - // An OVERLAY over the body's top rows, at the right edge, one - // chip to a row in the order collectNotices chose -- not a row - // taken out of the body the way a tree-sitter context header is. - // The chip is as wide as its own message and no wider, so the text - // beneath the rest of the row keeps reading as the file it is. - const first = p.bodyTop(pane, r); + // An OVERLAY over the body's top rows, at the right edge (placed + // by `place`) -- not a row taken out of the body the way a + // tree-sitter context header is. var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const row = first + @as(u16, pane.notices.row[i]); + { + const row = region.rect.y; // Every notice is painted on the canonical grid as well as // emitted as a tag layer: the grid is what a terminal client // draws, and a band it cannot see is a message it never gets. // The GUI skips grid cells a tag layer covers, so nothing is // drawn twice there. const text = Messages.noticeText(p, pane, i, &leader_buf); - const chip = Messages.noticeCols(p, text, tw); - if (chip == 0) continue; - const cx = tx + tw - chip; - pane.notices.left[i] = cx; + const chip = region.rect.w; + const cx = region.rect.x; // A message coming or going blends toward the page it sits // on; a grid cannot move it by less than a row. var style = msg_style; @@ -183,16 +282,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], .edge => |x| x, }; - // Leave the outside insertion rail visible above compact - // text layers. This clips presentation only, never reflows. - if (edge == s.cols) { - for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { - layer.viewport.w -|= 1; - }; - for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) { - layer.viewport.w -|= 1; - }; - } + // 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. @@ -441,8 +532,9 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: // 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)) return; + 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 @@ -454,12 +546,12 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: // 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(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); + 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.renderBody(p, s, arena, pane, r, id, active, true); + try body_layer.renderBody(p, s, arena, pane, r, id, active, rail.*); } /// The workspace and column tags as the grid shows them: their text, the @@ -608,6 +700,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; + const region = p.surface.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 @@ -632,7 +725,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { layer.id = @intCast(id); layer.serial = pane.serial; layer.line = @intCast(line); - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; layer.cols = columns; layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| @@ -659,18 +752,19 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { // GUI's "skip the cells a tag layer covers" leaves no stub behind. // A prompt is one of them too: left on the grid, a pixel shell drew // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; + for (p.surface.regionList()) |region| { + if (region.kind != .notice) continue; + 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]; - if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; - const first = p.bodyTop(pane, r); + if (r.w <= TAG_TEXT_INSET) continue; var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { + { const text = Messages.noticeText(p, pane, i, &leader_buf); if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; + const chip = region.rect.w; // Right aligned inside the chip, a blank cell short of its // edge: the same one the grid pass leaves for a prompt caret. // Wider than the chip, the text loses its head, as on the grid. @@ -693,11 +787,13 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const page = p.theme().bg orelse chrome.tag_bg; const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), + .x = region.rect.x, + .y = region.rect.y, .w = chip, .h = BOX_H, }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + // Laid out at its full width; only shown short of the rail. + p.surface.tag_layers[layer_index].viewport.w -= region.clip; p.surface.tag_layers[layer_index].slide = motion.slide; p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, @@ -713,9 +809,11 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { 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; + const region = p.surface.region(.column_tag, column) orelse continue; 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 + top), 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 = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .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); + p.surface.tag_layers[index].viewport.w -= region.clip; } }; } @@ -903,3 +1001,77 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, 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); + if (r.w > TAG_TEXT_INSET) for (0..pane.tag_rows) |line| { + const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }, layer.viewport); + }; + 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); + } + } +} diff --git a/src/pardes.zig b/src/pardes.zig index 161a047c..6946e123 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -38,6 +38,7 @@ pub const NativePlacement = @import("surface.zig").NativePlacement; pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; +pub const Region = @import("surface.zig").Region; pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 78d3de27..13c02231 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2548,6 +2548,7 @@ pub fn draw( pane_id: usize, text_x: u16, text_width: u16, + rail: *pardes.Region, ) bool { if (comptime !enabled) return false; if (!core.native_images or rect.h <= pane.tag_rows) return false; @@ -2617,16 +2618,12 @@ pub fn draw( // This frame has spent the motion used to choose its raster band. state.scroll_travel = 0; - const body_y = core.bodyTop(pane, rect); - const body_h = rect.h -| pane.tag_rows; const chrome = core.chromeTheme(); const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; - // The rail runs past the notice bands; only the thumb tracks the text. - s.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg }); - - const track_h: usize = body_h; + // The rail runs past the notice bands; only the thumb tracks the text, + // and it is measured here, in document pixels, for `place`'s region. + const track_h: usize = rail.rect.h; const total = @max(@as(u64, 1), state.document_height); const len = @max( @as(usize, 1), @@ -2640,15 +2637,11 @@ pub fn draw( @as(u64, track_h -| 1), @as(u64, track_h) * offset / total, )); - var y = pos; - while (y < track_h and y < pos + len) : (y += 1) - s.fill( - rect.x, - body_y + @as(u16, @intCast(y)), - 1, - 1, - .{ .bg = .{ .rgb = chrome.scroll_thumb } }, - ); + rail.thumb_y = @intCast(pos); + rail.thumb_h = @intCast(@min(len, track_h -| pos)); + 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 }); + s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); return true; } diff --git a/src/surface.zig b/src/surface.zig index eef787b4..b19c24c7 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -324,6 +324,51 @@ test "pixel attachment fit and pan do not invalidate cached pixels" { try std.testing.expect(key.eql(place.cacheKey())); } +/// One part of the frame and where it is, placed once by `draw.place` +/// before anything is painted: every painter and every shell reads the +/// geometry here instead of working it out again. `rect` is in grid cells +/// and is the hit-test geometry. +pub const Region = struct { + pub const Kind = enum(u8) { + /// The frame-wide border fill every other part is painted over. + page, + /// A pane's grip: the mode mark at the head of its tag rows. + grip, + /// A pane's tag rows, right of its grip. + tag, + /// A pane's body, right of its rail: absent while it is collapsed. + body, + /// The scroll rail down a pane's left edge, beside its body. + rail, + /// A transient band over the body's top rows (a message, a prompt). + notice, + column_grip, + column_tag, + workspace_tag, + }; + pub const none = std.math.maxInt(u8); + + kind: Kind, + /// The pane slot, or the column, the part belongs to; `none` for the + /// page and the workspace tag. + owner: u8 = none, + serial: u32 = 0, + rect: layout.Rect, + /// The pane or column has the keyboard's focus. + active: bool = false, + /// A rail's thumb: rows from the rail's top, and how many. Zero rows is + /// a rail with no thumb (an image). + thumb_y: u16 = 0, + thumb_h: u16 = 0, + /// A notice's index in its pane's `notices`. + notice: u8 = 0, + /// Cells the column-move rail takes off the right edge while it runs + /// there: layers and pixel shells stop short of them, the grid does not. + clip: u16 = 0, +}; + +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; + /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { @@ -337,6 +382,8 @@ pub const Surface = struct { tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, + regions: [MAX_REGIONS]Region = undefined, + nregions: u16 = 0, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -357,6 +404,16 @@ pub const Surface = struct { return &s.body_layers; } + pub fn regionList(s: *const Surface) []const Region { + return s.regions[0..s.nregions]; + } + + /// The first part of `kind` that `owner` has: its tag, its rail. + pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { + for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; + return null; + } + pub fn panelTracks(s: *const Surface) []const animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } -- cgit v1.3