diff options
Diffstat (limited to 'src/draw.zig')
| -rw-r--r-- | src/draw.zig | 258 |
1 files changed, 215 insertions, 43 deletions
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); + } + } +} |
