summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/body_layer.zig37
-rw-r--r--src/draw.zig258
-rw-r--r--src/pardes.zig1
-rw-r--r--src/pdf_view.zig25
-rw-r--r--src/surface.zig57
5 files changed, 291 insertions, 87 deletions
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];
}