From 30c2c8a154e770b3920bcfd7d553825fe9182bf4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:57:47 -0300 Subject: Move the frame into draw.zig and the transitions out of layout.zig Pure moves, no behaviour change. draw.zig holds the whole core frame in order: render, the pane, tag, header and notice painters it calls, and the character-effect composition (Pardes.render stays a declaration alias). Presentation.zig is the panel presentation state as a file struct, and animation.zig the easing curves, transition kinds, tracks and boxes, the character effects' sources and the generic displayed-value transition, all of which lived in layout.zig. layout.zig keeps only layout. --- src/surface.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'src/surface.zig') diff --git a/src/surface.zig b/src/surface.zig index a9e69cca..eef787b4 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -3,6 +3,7 @@ const pardes = @import("pardes.zig"); const std = @import("std"); const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const panes = @import("panes.zig"); @@ -70,7 +71,7 @@ pub const AsciiDiff = struct { /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. - pub const max_movement_frames = layout.ascii_max_movement_frames; + pub const max_movement_frames = animation.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; @@ -341,7 +342,7 @@ pub const Surface = struct { cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, images: []?ImagePlace = &.{}, nimages: usize = 0, - panel_tracks: [MAX_PANES * 2]layout.Track = undefined, + panel_tracks: [MAX_PANES * 2]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, previous_body_layers: []const BodyLayer = &.{}, @@ -356,7 +357,7 @@ pub const Surface = struct { return &s.body_layers; } - pub fn panelTracks(s: *const Surface) []const layout.Track { + pub fn panelTracks(s: *const Surface) []const animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } -- cgit v1.3 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/surface.zig') 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 From 0de2e7f04977b22df3f7b6ab1690eb8a6c905cd4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:57:28 -0300 Subject: Paint tags, notices and headers once, and join them into the grid A pane's tag rows, each notice chip and the workspace and column tags are painted once, as their tag layers, and the grid's cells for them are copies of those layers (joinCells): the second paint of every tag, the grid's own notice pass and paintHeaders' grid text, hover, selection and caret are gone, and so is renderTagLayers. The grid's caret for a tag, a header or a prompt is the layer's, projected to grid columns. A wide glyph cut by the edge of the copy is left as the band's blank, as the grid's print never drew half of one (a test proves it, and fails without the re-clip). Where the two copies disagreed, the grid's behaviour is kept, so every snapshot golden is byte-identical: - a notice highlighted the hovered word only as a layer; now it never does; - a column tag's hover was suppressed during a column move only on the grid; now in both; - a header under edit showed the hovered word only on the grid; now in both, under the selection; - a notice's leading padding carried the band's ink only as a layer; the grid copy keeps it blank; - a notice whose band is wider in tagline pitch than in grid cells (a GUI core) now shows the band's right end on the grid, where its text is, and its prompt caret there. A sweep begun on a tag and dragged into the body still shows on the body rows it crossed, on the grid only (paintPointerSelection's from_row). --- src/body_layer.zig | 8 +- src/draw.zig | 565 +++++++++++++++++++++++++---------------------------- src/surface.zig | 4 +- 3 files changed, 271 insertions(+), 306 deletions(-) (limited to 'src/surface.zig') diff --git a/src/body_layer.zig b/src/body_layer.zig index 2b6ae773..e1a45058 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -228,6 +228,8 @@ pub fn paintPointerSelection( tag_y: u16, body_y: u16, sl: Pane.Sel, + /// Rows of the selection above this are left alone. + from_row: u16, bg: [3]u8, fg: ?[3]u8, ) void { @@ -238,7 +240,7 @@ pub fn paintPointerSelection( const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { - if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; + if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) continue; const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. @@ -363,7 +365,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (preview.file_word) |word| panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -376,7 +378,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, 0, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/draw.zig b/src/draw.zig index e87e0511..b596fc22 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -4,6 +4,7 @@ //! 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"); @@ -179,7 +180,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.previous_tag_layers = &.{}; 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. const whole = s.regions[0].rect; @@ -194,69 +194,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } + // Each notice once, as a tag layer, and the grid's chip a copy of it. 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; - // Painted the way the sticky context headers above them are: the - // tagline font and the tagline's own colours, so a notice reads as - // a band belonging to the pane rather than as a line of body text - // that happens to sit at the bottom of it. - const msg_style: CellStyle = .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }; - // 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; - { - 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 = 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; - if (Messages.noticeLife(pane, i)) |life| { - // No page colour to fade to (a terminal's own default): - // the text dissolves into its chip instead. - const alpha = Messages.messageMotion(p, life).alpha; - const page = th.bg orelse chrome.tag_bg; - style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; - style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; - } - s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); - // One column short of the chip's right edge: a prompt's caret - // sits AFTER its last character the moment it opens, and flush - // right would put it past the pane, off the grid, and past what - // the detached wire will encode -- which drops every frame for - // as long as the prompt is up. - const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - // The cursor follows the text to wherever it landed; a caret - // in the part a narrow band dropped has nowhere to be. - 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) continue; - const caret = printed.left + (prompt_col - printed.dropped); - if (caret < tx + tw) - s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert }; - } + if (region.kind == .notice) try joinNotice(p, s, arena, region); } - try paintHeaders(p, s); - try renderTagLayers(p, arena); + try paintHeaders(p, s, arena); switch (p.drag) { .border_v => |d| { @@ -526,7 +469,7 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: .tty => config.tag_tty, }, box_style); - try paintPaneTag(p, s, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + 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. @@ -554,121 +497,85 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: 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 -/// word under the pointer and, in the one being edited, its selection and -/// caret. Their tag layers are drawn by renderTagLayers. -pub fn paintHeaders(p: *Pardes, s: *Surface) !void { +/// 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(); - const th = p.theme(); // 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); - const global = tagline.topbar(p); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row + global_top); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row + global_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; - while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } + if (s.region(.workspace_tag, Region.none)) |region| { + const top = if (p.header_focus and p.header_column == null) p.header_top else 0; + for (0..region.rect.h) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); + joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); } } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; - const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; - const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - // 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 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 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; - const tag = tagline.columnTag(p, c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row + bar_top); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); + 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 }); } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } + const top = if (p.header_focus and p.header_column == c) p.header_top else 0; + for (0..column_rows) |line| { + const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .column, @intCast(c), @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, c), line + top), bg, fg); + if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); + // Laid out at its full width; only shown short of the rail. + s.tag_layers[index].viewport.w -= region.clip; } }; - if (tagline.focusedHeader(p)) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row + p.header_top); - const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - // A prompt answering for the header has the caret on its band. - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - const cur: usize = @intCast(@max(0, t.cur_row)); - if (!asking and cur >= p.header_top and cur < p.header_top + rows) { - const bar = modal.lineSlice(text, cur); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; - } +} + +/// Copy a header line's layer into the grid under it, and its caret. +fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { + const v = layer.viewport; + const w = @min(v.w, s.cols -| v.x); + joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); + if (layer.cursor) |caret| { + if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.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()) { var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); @@ -694,130 +601,6 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { return .{ .lo = start + basename, .hi = start + name.len }; } -pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - for (p.panes, 0..) |slot, id| { - 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 - // its viewport. - var widest: usize = 0; - var split = std.mem.splitScalar(u8, tag, '\n'); - for (0..pane.tag_top) |_| _ = split.next(); - for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); - const pitch = @as(usize, columns) + TAG_TEXT_INSET; - 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, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)); - // A layer to a line: the first in the pane's own slot, the rest - // in the slots kept for the lines of taller tags. - for (0..rows) |line| { - const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - 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| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; - } - } - // With the workspace tag row handed to native chrome there is no - // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - const top = if (p.header_focus and p.header_column == null) p.header_top else 0; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // 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.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) continue; - var leader_buf: [16]u8 = undefined; - { - const text = Messages.noticeText(p, pane, i, &leader_buf); - if (text.len == 0) continue; - 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. - const room = p.tagCapacity(chip) -| 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); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - 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 = 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, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - 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) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - const top = if (p.header_focus and p.header_column == column) p.header_top else 0; - 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 = 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; - } - }; -} - /// One row of a header or a notice band as a tag layer: `text` is line /// `line` of what it shows. fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { @@ -851,19 +634,11 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T const scroll = if (editing != null) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - 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(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.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 = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { + // The word under the pointer: on a header even while it is edited, but + // not on a notice (a message is not a command line), nor on a column a + // carried column's pointer is aiming at a place over. + const hovered = p.pointer_inside and kind != .notice 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) @@ -880,6 +655,160 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T } }; } + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { + var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + 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(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.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 = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + } +} + +/// A pane's tag rows, painted once into their layers, a layer 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 }; + // A layer to a line: the first in the pane's own slot, the rest in the + // slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + 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 (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| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + // 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 sweep begun on the tag and dragged into the body shows on the body + // rows it crossed, which only the grid has. + const th = p.theme(); + const tx = r.x + TAG_TEXT_INSET; + const tw = r.w - TAG_TEXT_INSET; + const tag_y = region.rect.y; + const body_y = p.bodyTop(pane, r); + 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 page_rgb = th.bg orelse th.tag_bg; + 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, rows, preview_bg, null); + }; + } + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, rows, th.sel_bg, th.sel_fg); + } +} + +/// 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); + const fg = Messages.blendRgb(chrome.tag_fg, 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) 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 index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); + const layer = &s.tag_layers[index]; + 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) 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 = .{ .col = @intCast(pad + typed - dropped), .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). + for (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.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @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 { @@ -957,7 +886,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa 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, preview_bg, null); + 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 @@ -998,7 +927,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); } } @@ -1075,3 +1004,35 @@ test "place puts every part where the painters and layers put it, in every layou } } } + +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); + } + } +} diff --git a/src/surface.zig b/src/surface.zig index b19c24c7..fec43e30 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -357,7 +357,9 @@ pub const Region = struct { /// 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). + /// a rail with no thumb (an image). A native PDF's thumb is measured in + /// document pixels and filled in by Pdf.draw while it paints, so read + /// regions once render has returned. thumb_y: u16 = 0, thumb_h: u16 = 0, /// A notice's index in its pane's `notices`. -- cgit v1.3 From d88e381793c6505bf3f2af43705bd2fa32f55ef6 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:37:29 -0300 Subject: One Layer for tags, notices, headers and bodies; a taller tag is one layer of rows; wire v8 src/Layer.zig merges TagLayer and BodyLayer. `rows` (0 = no layer) and a cursor at {x, y}. A tag of N rows is ONE layer of N grid rows: tagHit answers the row as `line`, bodyHit keeps its meaning, and the per-line layer bases (TAG_LINE_LAYER_BASE, HEADER_LINE_LAYER_BASE) are gone, not aliased. Wire v8, the one bump: tag layers carry rows and cursor y, and the frame carries the placed region list. v7 and v9 peers are refused in both directions (server test over both, a new frontend test over both). web: tag_layer_value 11 = rows, 12 = cursor y; app.mjs lays every row. macOS: the Zig side compiles against Layer; pardes.h still sees one row per tag layer (accepted, the macOS shell is ignored for now). No visual change: snapshot goldens and the 17 GUI goldens byte-identical. Shared files touched: pardes.zig, Messages.zig, gui.zig, macos.zig, detached/client.zig, detached/server.zig, detached/wire.zig (plus web.zig, app.mjs, edit.zig, look.zig). Not touched: mouse.zig, tagline.zig, colors.zig, tty.zig, dump.zig, exec.zig, host_io.zig, panes.zig. --- docs/render-pipeline.md | 2 +- src/Layer.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Messages.zig | 6 +- src/Presentation.zig | 12 +-- src/body_layer.zig | 117 ++-------------------------- src/detached/client.zig | 30 ++++++-- src/detached/server.zig | 3 +- src/detached/wire.zig | 118 ++++++++++++++++++++--------- src/draw.zig | 187 +++++++++++++++++++++++---------------------- src/edit.zig | 2 +- src/gui/gui.zig | 139 +++++++++++++++++---------------- src/look.zig | 2 +- src/macos.zig | 12 +-- src/pardes.zig | 40 +++++----- src/surface.zig | 15 ++-- src/tag_layer.zig | 87 ++------------------- src/web.zig | 12 ++- src/web/app.mjs | 38 ++++++---- test/column_drag.zig | 10 +-- test/panes.zig | 30 ++++---- 20 files changed, 590 insertions(+), 470 deletions(-) create mode 100644 src/Layer.zig (limited to 'src/surface.zig') diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index f63b9a0b..a8b3f019 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -809,7 +809,7 @@ stripped. No visual change until stage 9 unless stated. | 4 | PLACE: Region list built once; renderPane/tags/notices read their rects from it | wait for tag→Text; the refactor is already replacing BOX_H with `pane.tag_rows` and `p.tagTop/bodyTop(pane, r)` — PLACE absorbs those into the region rects | | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | -| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now) | +| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | diff --git a/src/Layer.zig b/src/Layer.zig new file mode 100644 index 00000000..e979eab7 --- /dev/null +++ b/src/Layer.zig @@ -0,0 +1,198 @@ +//! A band of cells a pixel shell draws over the grid at its own pitch: a +//! pane's, a column's or the workspace's tag rows, a notice chip, or a file +//! body whose compact tree-sitter context rows outnumber its grid rows. The +//! grid under it holds a copy of what fits there (draw.zig joins them), so a +//! terminal client, which has only the grid, loses nothing but the pitch. +//! +//! Its viewport is in grid cells, so compact text never moves a hit target. +//! A tag's rows are each one grid row, their glyphs at the tagline's size; a +//! body's first `context_rows` rows are stacked at the tagline's height, the +//! rest at the body's. `rows == 0` is no layer. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const limits = @import("memory.zig").limits; + +const Layer = @This(); + +/// `notice` is a pane's transient line -- a builtin's name, a pending leader +/// chord -- carried as a tagline-shaped band so it gets the tagline's height, +/// small-font metrics, band offset and border instead of being a body-grid row +/// wearing a tagline font. +pub const Kind = enum(u8) { workspace, column, pane, notice, body }; + +pub const Metrics = struct { + body_w: u16 = 1, + body_h: u16 = 1, + tagline_w: u16 = 1, + tagline_h: u16 = 1, +}; + +/// A point on a tag, notice or header layer: which row of it and which +/// column at the tagline's pitch. +pub const TagHit = struct { + kind: Kind, + id: u16, + serial: u32, + col: u16, + /// Which row of a taller tag. + line: u16 = 0, + pixel_x: f32, + pixel_y: f32, + metrics: Metrics, +}; + +kind: Kind = .body, +/// The pane slot, or the column, it belongs to. +id: u16 = 0, +serial: u32 = 0, +viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, +cols: u16 = 0, +rows: u16 = 0, +/// A body's compact rows, from its top. +context_rows: u16 = 0, +context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), +/// `rows * cols`, row after row. +cells: []pardes.Cell = &.{}, +cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, +/// A band's fill, where its cells run out. +bg: [3]u8 = .{ 0, 0, 0 }, +/// Vertical offset in rows (negative: above its viewport), for a band +/// that is moving into place. A pixel shell draws it offset and clipped +/// to the rows below the viewport's top; the grid has no such thing. +slide: f32 = 0, +/// How present a notice band is (1 = fully) while it fades up or +/// dissolves. `bg` and the cells are already blended by it; a pixel +/// shell uses it for what it draws around the band, like its rule. +fade: f32 = 1, + +/// Tag columns a viewport this wide holds at the tagline's pitch. +pub fn capacity(layer: *const Layer, metrics: Metrics) u16 { + return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); +} + +pub fn setContextLines(layer: *Layer, lines: []const i32) void { + layer.context_separators = .initEmpty(); + const count = @min(lines.len, layer.context_rows); + var index: usize = 0; + while (index + 1 < count and index < limits.wrap_rows) : (index += 1) { + if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index); + } +} + +pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool { + if (index >= layer.context_rows or index >= limits.wrap_rows) return false; + return index + 1 == layer.context_rows or layer.context_separators.isSet(index); +} + +/// Pixel top of row `index`: a body's compact rows stacked at the tagline's +/// height, every other row a grid row. +pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + const body = @max(1, body_h); + const tag = std.math.clamp(tagline_h, 1, body); + return @as(f32, @floatFromInt(layer.viewport.y)) * body + + @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag + + @as(f32, @floatFromInt(index -| layer.context_rows)) * body; +} + +pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); +} + +const Point = struct { index: u16, col: u16, metrics: Metrics }; + +/// The row and column a window pixel lands on, each row at its own pitch: +/// a tag's at the tagline's width, a body's compact rows too. +fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { + if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; + // A band's hit is refused outright on metrics no host could have; a + // body's has always been floored to a pixel instead. + if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| + if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + const bw = @max(1, body_w); + const bh = @max(1, body_h); + const tw = std.math.clamp(tagline_w, 1, bw); + const th = std.math.clamp(tagline_h, 1, bh); + const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; + if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; + const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; + const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); + if (index >= layer.rows) return null; + const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw; + return .{ + .index = index, + .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))), + .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, + }; +} + +/// A body layer's pixel as the body cell under it (grid columns, from the +/// pane's text edge). +pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { + if (layer.kind != .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +/// A band's pixel as the tag cell under it. +pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit { + if (layer.kind == .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +test "compact body layer rows fill reclaimed height and invert exact boundaries" { + const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; + try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); + try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); + try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); + try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); + for (0..12) |index| { + const r: u16 = @intCast(index); + const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2; + const width: f32 = if (r < 5) 6 else 10; + const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?; + try std.testing.expectEqual(r, hit.row); + try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); + } + try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row); + try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null); +} + +test "compact context borders separate source gaps and preserve multiline headers" { + var layer: Layer = .{ .context_rows = 6 }; + layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); + const expected = [_]bool{ false, true, false, false, true, true, false }; + for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index)); + layer.context_rows = 2; + layer.setContextLines(&.{ 9, 10 }); + try std.testing.expect(!layer.hasContextBorderAfter(0)); + try std.testing.expect(layer.hasContextBorderAfter(1)); + try std.testing.expect(!layer.hasContextBorderAfter(4)); + try std.testing.expect(!layer.context_separators.isSet(4)); +} + +test "tag text capacity uses its own pitch and excludes the physical grip" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 }; + try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); + try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null); + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col); + try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col); + try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null); +} + +test "a taller tag's rows are grid rows, each hit on its own line" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 }; + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line); + try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null); +} diff --git a/src/Messages.zig b/src/Messages.zig index 7334de95..b7153e5c 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -747,7 +747,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" { _ = try p.render(frame.allocator()); const r = p.rects[p.active]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(@as(u16, 1), band.viewport.h); try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y); @@ -794,7 +794,7 @@ test "notices stack over the body's top rows without moving them" { var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; try std.testing.expectEqual(@as(u16, 1), layer.viewport.h); // Sized to its own message, right up against the pane's right edge -- // not the width of the pane. @@ -814,7 +814,7 @@ test "notices stack over the body's top rows without moving them" { _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); for (p.surface.tagLayers()) |*layer| - try std.testing.expect(layer.cols == 0 or layer.kind != .notice); + try std.testing.expect(layer.rows == 0 or layer.kind != .notice); } test "an idle core wants no wake and a lingering message sleeps to its linger's end" { diff --git a/src/Presentation.zig b/src/Presentation.zig index 45b196c8..e1f1b585 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -42,8 +42,8 @@ shown_closing_len: usize = 0, acknowledged: bool = false, pending: bool = false, previous_cells: []pardes.Cell = &.{}, -previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), -previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), +previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), +previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -335,7 +335,7 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { - const count = if (current.cols > 0) current.cells.len else 0; + const count = if (current.rows > 0) current.cells.len else 0; const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { self.previous_valid = false; self.previous_layout = @splat(null); @@ -684,7 +684,7 @@ test "presentation submission failure preserves shown geometry until retry is ac } test "fractional panel inverse preserves compact header rows through slide and zoom" { - const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; + const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { var presentation: Presentation = .{}; const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; @@ -699,7 +699,7 @@ test "fractional panel inverse preserves compact header rows through slide and z const point = presentation.pointerFractional(80, 30, x, y).?; try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); - const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; + const body_hit = layer.bodyHit(point.x * 10, point.y * 20, 10, 20, 8, 10).?; try std.testing.expectEqual(row, body_hit.row); } } @@ -715,7 +715,7 @@ test "presentation snapshots own compact body and tag rows across later edits" { surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; var tag: [1]pardes.Cell = .{.{}}; tag[0].text[0] = 'T'; - surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; + surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .rows = 1, .cells = &tag }; state.capturePrevious(std.testing.allocator, &surface); try std.testing.expect(state.previous_valid); tag[0].text[0] = 'U'; diff --git a/src/body_layer.zig b/src/body_layer.zig index ac87a07b..583a2e98 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -1,9 +1,6 @@ -//! A pane body as the surface shows it. A file body's logical rows can -//! outnumber its physical grid rows: hosts place the compact declaration rows -//! first and clip the remaining body rows. After the layer types come the -//! functions that draw a body into the surface: its text, colours, selections -//! and cursor; then the tags drawn beside it, a pane's and the column and -//! workspace ones, each line a tag layer of its own. +//! Drawing a pane body into a surface: its text, colours, selections and +//! cursor, and joining a body with compact context rows through its layer +//! (Layer.zig). const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); @@ -29,7 +26,7 @@ const tagline = @import("tagline.zig"); const Messages = @import("Messages.zig"); const Text = panes.Text; const MAX_PANES = pardes.MAX_PANES; -const TagKind = @import("tag_layer.zig").Kind; +const TagKind = @import("Layer.zig").Kind; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; @@ -40,108 +37,7 @@ const COLBAR_H = pardes.COLBAR_H; const TAG_GAP = pardes.TAG_GAP; const wordBoundsAtCol = pardes.wordBoundsAtCol; -pub const Metrics = struct { - body_w: u16 = 1, - body_h: u16 = 1, - tagline_w: u16 = 1, - tagline_h: u16 = 1, -}; - -pub const Layer = struct { - pane: u16 = 0, - serial: u32 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - rows: u16 = 0, - context_rows: u16 = 0, - context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), - cells: []pardes.Cell = &.{}, - cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - - pub fn setContextLines(layer: *Layer, lines: []const i32) void { - layer.context_separators = .initEmpty(); - const count = @min(lines.len, layer.context_rows); - var row: usize = 0; - while (row + 1 < count and row < limits.wrap_rows) : (row += 1) { - if (lines[row + 1] != lines[row] +| 1) layer.context_separators.set(row); - } - } - - pub fn hasContextBorderAfter(layer: *const Layer, row: usize) bool { - if (row >= layer.context_rows or row >= limits.wrap_rows) return false; - return row + 1 == layer.context_rows or layer.context_separators.isSet(row); - } - - pub fn rowTop(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - const body = @max(1, body_h); - const tag = std.math.clamp(tagline_h, 1, body); - return @as(f32, @floatFromInt(layer.viewport.y)) * body + - @as(f32, @floatFromInt(@min(row, layer.context_rows))) * tag + - @as(f32, @floatFromInt(row -| layer.context_rows)) * body; - } - - pub fn rowHeight(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - return if (row < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { - if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; - const row: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); - if (row >= layer.rows) return null; - const col: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / (if (row < layer.context_rows) tw else bw)))); - return .{ - .serial = layer.serial, - .row = row, - .col = col + config.GUTTER, - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "compact body layer rows fill reclaimed height and invert exact boundaries" { - const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; - try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); - try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); - try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); - try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); - for (0..12) |index| { - const row: u16 = @intCast(index); - const y = layer.rowTop(row, 20, 12) + layer.rowHeight(row, 20, 12) / 2; - const width: f32 = if (row < 5) 6 else 10; - const hit = layer.hitAt(40 + 3.5 * width, y, 10, 20, 6, 12).?; - try std.testing.expectEqual(row, hit.row); - try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); - } - try std.testing.expectEqual(@as(u16, 4), layer.hitAt(45, 119.99, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 5), layer.hitAt(45, 120, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 11), layer.hitAt(45, 259.99, 10, 20, 6, 12).?.row); - try std.testing.expect(layer.hitAt(45, 260, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(39.99, 80, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(std.math.nan(f32), 80, 10, 20, 6, 12) == null); -} - -test "compact context borders separate source gaps and preserve multiline headers" { - var layer: Layer = .{ .context_rows = 6 }; - layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); - const expected = [_]bool{ false, true, false, false, true, true, false }; - for (expected, 0..) |border, row| try std.testing.expectEqual(border, layer.hasContextBorderAfter(row)); - layer.context_rows = 2; - layer.setContextLines(&.{ 9, 10 }); - try std.testing.expect(!layer.hasContextBorderAfter(0)); - try std.testing.expect(layer.hasContextBorderAfter(1)); - try std.testing.expect(!layer.hasContextBorderAfter(4)); - try std.testing.expect(!layer.context_separators.isSet(4)); -} +const Layer = @import("Layer.zig"); // ---- drawing a pane body into the surface ---- @@ -305,7 +201,8 @@ pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } - layer.pane = @intCast(id); + layer.kind = .body; + layer.id = @intCast(id); layer.serial = pane.serial; const body = s.region(.body, id).?; layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; diff --git a/src/detached/client.zig b/src/detached/client.zig index 4468c961..3347f609 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -103,8 +103,10 @@ pub const Client = struct { grid: std.ArrayListUnmanaged(pardes.Cell) = .empty, cursor: ?wire.Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, - body_layers: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}), - tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), + body_layers: [pardes.MAX_PANES]pardes.Layer = @splat(.{}), + tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), + /// The session's placed regions, as its last frame gave them. + regions: std.ArrayListUnmanaged(pardes.Region) = .empty, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -182,6 +184,7 @@ pub const Client = struct { layer.* = .{}; } for (&c.tag_layers) |*layer| c.gpa.free(layer.cells); + c.regions.deinit(c.gpa); c.in.deinit(c.gpa); c.out.deinit(c.gpa); } @@ -319,7 +322,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; @@ -995,6 +998,12 @@ test "detached session: a frontend that dies takes nothing with it" { } test "detached session: a frontend speaking another protocol is refused, loudly" { + // The protocol before this one (v7 carried one-row tags and no regions) + // and the one after. + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| try refusedHello(claimed); +} + +fn refusedHello(claimed: u16) !void { var h: Harness = undefined; try h.init(60, 16); defer h.deinit(); @@ -1006,7 +1015,7 @@ test "detached session: a frontend speaking another protocol is refused, loudly" defer _ = libc.close(fd); var buf: [64]u8 = undefined; const hello = try wire.encodeClient(&buf, .{ - .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 }, + .hello = .{ .version = claimed, .cols = 60, .rows = 16 }, }); try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal)); @@ -1024,6 +1033,17 @@ test "detached session: a frontend speaking another protocol is refused, loudly" try testing.expect(!h.core.quit); } +test "detached frontend: a session speaking another protocol is not attached to" { + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| { + var c: Client = .{ .gpa = testing.allocator }; + defer c.deinit(); + var buf: [64]u8 = undefined; + try c.in.appendSlice(testing.allocator, try wire.encodeServer(&buf, .{ .welcome = .{ .version = claimed, .slot = 0, .cols = 60, .rows = 16 } })); + try testing.expectError(error.Ungreeted, c.next()); + try testing.expect(!c.attached()); + } +} + test "detached session: a peer that sends garbage is dropped, not obeyed" { var h: Harness = undefined; try h.init(60, 16); @@ -1281,7 +1301,7 @@ fn pumpUntilNotice(h: *Harness, c: *Client, want: []const u8) !bool { try c.wait(5); while (try c.next()) |_| {} for (&c.tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; var n: usize = 0; for (layer.cells[0..layer.cols]) |cell| { const g = cell.grapheme(); diff --git a/src/detached/server.zig b/src/detached/server.zig index 212c9a3d..46fb8fcf 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -739,7 +739,7 @@ pub const Session = struct { fn sendFrame(s: *Session, c: *Client, surface: *const pardes.Surface) void { const cells = surface.cells; - const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers); + const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers, surface.regionList()); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len) &.{} @@ -759,6 +759,7 @@ pub const Session = struct { prev, &surface.body_layers, &surface.tag_layers, + surface.regionList(), ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 94bd7f0b..684c869b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -7,7 +7,9 @@ const limits = @import("../memory.zig").limits; /// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value /// an older frontend would reject mid-session. The greeting is where that has /// to be refused, which is what this number is for. -pub const version: u16 = 7; +/// 8: one layer type: a tag band carries all its rows (not a layer per +/// line), and a frame carries the core's placed regions after its layers. +pub const version: u16 = 8; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; @@ -167,7 +169,8 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer, tags: *[pardes.MAX_TAG_LAYERS]pardes.TagLayer) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + regions.clearRetainingCapacity(); for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -191,13 +194,14 @@ pub const Frame = struct { const count = try r.getU16(); if (count > layers.len) return error.Overlong; for (layers[0..count]) |*layer| { - layer.pane = try r.getU16(); + layer.kind = .body; + layer.id = try r.getU16(); layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); layer.rows = try r.getU16(); layer.context_rows = try r.getU16(); - if (layer.pane >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or + if (layer.id >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.rows or layer.context_rows > limits.wrap_rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or layer.viewport.y > f.rows or layer.viewport.h > f.rows - layer.viewport.y) return error.BadValue; @@ -217,19 +221,40 @@ pub const Frame = struct { layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); + layer.rows = try r.getU16(); for (&layer.bg) |*channel| channel.* = try r.getByte(); - if (layer.cols == 0 or layer.viewport.w == 0 or layer.viewport.h != 1 or + if (layer.cols == 0 or layer.viewport.w == 0 or layer.rows == 0 or layer.rows > pardes.MAX_TAG_ROWS or + layer.viewport.h != layer.rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or - layer.viewport.y >= f.rows or (switch (layer.kind) { + layer.viewport.y >= f.rows or layer.viewport.h > f.rows - layer.viewport.y or (switch (layer.kind) { .workspace => layer.id != 0, .column => layer.id >= pardes.MAX_COLS, .pane, .notice => layer.id >= pardes.MAX_PANES, + .body => true, })) return error.BadValue; - const cursor = try getCursor(&r, layer.cols, 1); - layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null; - layer.cells = try gpa.alloc(pardes.Cell, layer.cols); + const cursor = try getCursor(&r, layer.cols, layer.rows); + layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null; + layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } + const region_count = try r.getU16(); + if (region_count > pardes.MAX_REGIONS) return error.Overlong; + try regions.ensureTotalCapacity(gpa, region_count); + for (0..region_count) |_| { + var region: pardes.Region = .{ .kind = try r.getTag(pardes.Region.Kind), .rect = undefined }; + region.owner = try r.getByte(); + region.serial = try r.getU32(); + region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; + region.active = try r.getBool(); + region.thumb_y = try r.getU16(); + region.thumb_h = try r.getU16(); + region.notice = try r.getByte(); + region.clip = try r.getU16(); + if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or + region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or + region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; + regions.appendAssumeCapacity(region); + } try r.end(); } @@ -672,21 +697,23 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); } -pub fn layersBound(layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) usize { - var n: usize = 4; +pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { + var n: usize = 6; for (layers) |layer| if (layer.rows != 0) { n += 26 + layer.context_rows + layer.cells.len * cell_max; }; - for (tags) |layer| if (layer.cols != 0) { - n += 26 + layer.cells.len * cell_max; + for (tags) |layer| if (layer.rows != 0) { + n += 28 + layer.cells.len * cell_max; }; - return n; + return n + regions.len * region_len; } -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) Error![]const u8 { +const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; + +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -708,7 +735,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point const layers_len_at = w.n; try w.putU32(0); const layers_start = w.n; - if (layers.len != 0 or tags.len != 0) { + if (layers.len != 0 or tags.len != 0 or regions.len != 0) { var count: u16 = 0; for (layers) |layer| if (layer.rows != 0) { count += 1; @@ -717,7 +744,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layers) |layer| { if (layer.rows == 0) continue; if (layer.context_rows > limits.wrap_rows or layer.context_rows > layer.rows) return error.BadValue; - try w.putU16(layer.pane); + try w.putU16(layer.id); try w.putU32(layer.serial); try w.putU16(layer.viewport.x); try w.putU16(layer.viewport.y); @@ -731,13 +758,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layer.cells) |*cell| try putCell(&w, cell); } var tag_count: u16 = 0; - for (tags) |layer| if (layer.cols != 0) { + for (tags) |layer| if (layer.rows != 0) { tag_count += 1; }; try w.putU16(tag_count); for (tags) |layer| { - if (layer.cols == 0) continue; - if (layer.cells.len != layer.cols) return error.BadValue; + if (layer.rows == 0) continue; + if (layer.cells.len != @as(usize, layer.cols) * layer.rows) return error.BadValue; try w.putByte(@intFromEnum(layer.kind)); try w.putU16(layer.id); try w.putU32(layer.serial); @@ -746,10 +773,26 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(layer.viewport.w); try w.putU16(layer.viewport.h); try w.putU16(layer.cols); + try w.putU16(layer.rows); for (layer.bg) |channel| try w.putByte(channel); - try putCursor(&w, if (layer.cursor) |c| .{ .x = c.col, .y = 0, .bar = c.bar } else null, layer.cols, 1); + try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows); for (layer.cells) |*cell| try putCell(&w, cell); } + try w.putU16(@intCast(regions.len)); + for (regions) |region| { + try w.putByte(@intFromEnum(region.kind)); + try w.putByte(region.owner); + try w.putU32(region.serial); + try w.putU16(region.rect.x); + try w.putU16(region.rect.y); + try w.putU16(region.rect.w); + try w.putU16(region.rect.h); + try w.putBool(region.active); + try w.putU16(region.thumb_y); + try w.putU16(region.thumb_h); + try w.putByte(region.notice); + try w.putU16(region.clip); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1820,22 +1863,26 @@ test "detached wire carries compact body rows and exact pointer metrics" { var cells: [8]pardes.Cell = @splat(.{}); var logical: [12]pardes.Cell = @splat(.{}); logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; - var layer: pardes.BodyLayer = .{ .pane = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; + var layer: pardes.Layer = .{ .id = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; layer.context_separators.set(0); var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; - const tag: pardes.TagLayer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .col = 2, .bar = true } }; - const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag})); + const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; + const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}); + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; - var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}); - var tags: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}); + var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); + var tags: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}); defer for (&tags) |*item| gpa.free(item.cells); defer for (&decoded) |*item| gpa.free(item.cells); try frame.apply(&cells); - try frame.applyLayers(gpa, &decoded, &tags); + var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; + defer regions.deinit(gpa); + try frame.applyLayers(gpa, &decoded, &tags, ®ions); + try std.testing.expectEqualDeep(placed[0..], regions.items); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1847,10 +1894,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expectEqual(tag.id, tags[0].id); try std.testing.expectEqual(tag.bg, tags[0].bg); try std.testing.expectEqual(tag.cols, tags[0].cols); - try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.col); + try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.x); try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme()); - const pointer = layer.hitAt(4, 27, 8, 16, 6, 8).?; - const tag_pointer = tag.hitAt(11, 8, 8, 16, 4, 8).?; + const pointer = layer.bodyHit(4, 27, 8, 16, 6, 8).?; + const tag_pointer = tag.tagHit(11, 8, 8, 16, 4, 8).?; const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } }; var mouse_bytes: [256]u8 = undefined; const mouse_encoded = try encodeClient(&mouse_bytes, message); @@ -1861,19 +1908,20 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); try std.testing.expectEqual(@as(usize, 0), item.context_separators.count()); } for (tags) |item| { - try std.testing.expectEqual(@as(u16, 0), item.cols); + try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); } + try std.testing.expectEqual(@as(usize, 0), regions.items.len); const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7d91ad31..7cfe7554 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -28,7 +28,7 @@ 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 TagKind = @import("Layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; const BOX_H = pardes.BOX_H; @@ -40,8 +40,6 @@ 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; -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) @@ -172,7 +170,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // never set. for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; - for (&s.tag_layers) |*layer| layer.cols = 0; + for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; @@ -505,11 +503,9 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { 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; - for (0..region.rect.h) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); - } + 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| { @@ -531,23 +527,21 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { 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; - for (0..column_rows) |line| { - const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(c), @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, c), line + top), bg, fg); - if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); - // Laid out at its full width; only shown short of the rail. - s.tag_layers[index].viewport.w -= region.clip; - } + 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 line's layer into the grid under it, and its caret. -fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { +/// 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); - joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); + 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.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar }; + if (caret.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.y, .bar = caret.bar }; } } @@ -600,77 +594,85 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { return .{ .lo = start + basename, .hi = start + name.len }; } -/// One row of a header or a notice band as a tag layer: `text` is line -/// `line` of what it shows. -fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { +/// 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; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; + if (viewport.w == 0 or viewport.h == 0) { + layer.rows = 0; return; } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + 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.line = line; 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; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // 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 => false, + .pane, .notice, .body => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - // The word under the pointer: on a header even while it is edited, but - // not on a notice (a message is not a command line), nor on a column a - // carried column's pointer is aiming at a place over. - const hovered = p.pointer_inside and kind != .notice 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 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(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, 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(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, 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 }; + 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 }); + _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + // The word under the pointer: on a header even while it is edited, + // but not on a notice (a message is not a command line), nor on a + // column a carried column's pointer is aiming at a place over. + const hovered = p.pointer_inside and kind != .notice 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 }; } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.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 = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } } -/// A pane's tag rows, painted once into their layers, a layer to a line, -/// and copied into the grid; the grid's caret where the layer has it. +/// 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. @@ -692,23 +694,26 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r 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 }; - // A layer to a line: the first in the pane's own slot, the rest in the - // slots kept for the lines of taller tags. + // 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| { - const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - 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 (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| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; + @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); } @@ -765,9 +770,8 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); - const layer = &s.tag_layers[index]; + 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. @@ -775,7 +779,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) 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 = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + 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). @@ -785,7 +789,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) // no ink of its own (the layer prints it as spaces in the band's ink). for (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.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; + 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; @@ -960,10 +964,11 @@ test "place puts every part where the painters and layers put it, in every layou 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); - }; + // 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); diff --git a/src/edit.zig b/src/edit.zig index b2d71ed2..9044d7ed 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -17,7 +17,7 @@ const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Mouse = pardes.Mouse; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a1e21dc0..ff99bd39 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1779,8 +1779,8 @@ const Gui = struct { tagline_percent: u8, tagline_width: u32, tagline_height: u32, - attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null, - attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.TagLayer = null, + attached_layers: ?*const [pardes.MAX_PANES]pardes.Layer = null, + attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.Layer = null, cell_w: u32, cell_h: u32, ascent: i32, @@ -3517,7 +3517,7 @@ fn gridCellAtDimensions( var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| { for (p.surface.tagLayers()) |*layer| { - if (layer.hitAt(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { + if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { point.tag_hit = hit; break; } @@ -3529,19 +3529,19 @@ fn gridCellAtDimensions( fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { var point = gridCellAtDimensions(core, x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width)); if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @as(f32, @floatFromInt(g.cell_w)), y / @as(f32, @floatFromInt(g.cell_h)))) |mapped| for (p.surface.bodyLayers()) |*layer| { - if (layer.hitAt(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + if (layer.bodyHit(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.body_hit = hit; break; } }; if (core == null) if (g.attached_layers) |layers| for (layers) |*layer| { - if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { + if (layer.bodyHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { point.body_hit = body_hit; break; } }; if (core == null) if (g.attached_tag_layers) |layers| for (layers) |*layer| { - if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + if (layer.tagHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.tag_hit = hit; break; } @@ -5203,18 +5203,18 @@ fn renderFrame( } } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += tagLayerCellCount(g, layer) + 1; + batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { const track = batch.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += tagLayerCellCount(g, layer) + 1; + destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); @@ -5303,7 +5303,7 @@ fn renderFrame( emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); } @@ -5312,7 +5312,7 @@ fn renderFrame( const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination: usize = if (under) 0 else batch_index; emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } @@ -5621,10 +5621,10 @@ test "compact column and pane underlays share focus tint without tinting the glo try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); } -fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const pardes.TagLayer { +fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; - if (layer.cols > 0 and row == r.y and col >= r.x and col - r.x < r.w) return layer; + if (layer.rows > 0 and row >= r.y and row - r.y < layer.rows and col >= r.x and col - r.x < r.w) return layer; } return null; } @@ -5633,9 +5633,9 @@ fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const parde /// themselves: the grid's cell loops skip them. Filling each layer's rectangle /// once replaces scanning every layer for every cell, which was a third of the /// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers only its viewport's -/// first row, and a viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) void { +/// and tagLayerIn find a layer for: a tag layer covers its rows, and a +/// viewport past the grid covers the grid's part of it. +fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { @memset(cover, false); for (body_layers) |*layer| { const r = layer.viewport; @@ -5647,12 +5647,13 @@ fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes. for (tag_layers) |*layer| { const r = layer.viewport; const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.cols == 0 or r.y >= rows or r.x >= right) continue; - @memset(cover[@as(usize, r.y) * cols ..][r.x..right], true); + if (layer.rows == 0 or r.x >= right) continue; + var y: u32 = r.y; + while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); } } -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) !void { +fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; try std.testing.expectEqual(scan, cover[row * cols + col]); @@ -5685,7 +5686,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand @memset(&cover, true); // what an earlier frame left coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); - for (s.tagLayers()) |layer| notices = notices or (layer.cols != 0 and layer.kind == .notice); + for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5702,8 +5703,8 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand }; var prng = std.Random.DefaultPrng.init(0x6c61796572); const random = prng.random(); - var body: [pardes.MAX_PANES]pardes.BodyLayer = undefined; - var tag: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = undefined; + var body: [pardes.MAX_PANES]pardes.Layer = undefined; + var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; var grid: [64 * 40]bool = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); @@ -5720,12 +5721,12 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } -fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 { +fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); } -fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { +fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5735,20 +5736,25 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p // A band sliding into place is drawn offset and cut at its row's top, so // it comes out from under whatever is above it. const slide = layer.slide * bh; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh; // A window is rarely a whole number of cells wide: a band that ends at // the last column runs on through the leftover pixels to the edge. const band_right = if (win_w - right < bw) @max(right, win_w) else right; - emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); - if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); - next.* += 1; - const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null; - for (0..tagLayerCellCount(g, layer)) |col| { - const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page); - clipInstanceRight(&instances[next.*], right, win_w); + // A row to each line, each a grid row with the tagline's glyphs. + for (0..layer.rows) |index| { + const y = layer.viewport.y + @as(u16, @intCast(index)); + const top = @as(f32, @floatFromInt(y)) * bh; + emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; + const cells = layer.cells[index * layer.cols ..][0..layer.cols]; + const line_cursor: ?usize = if (layer.cursor) |cu| (if (cu.y == index) cu.x else null) else null; + for (0..tagLayerCellCount(g, layer)) |col| { + const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == index else false; + emitInstance(g, instances, next.*, @intCast(col), y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, cells, col, line_cursor, old_layer, cursor, page); + clipInstanceRight(&instances[next.*], right, win_w); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); + next.* += 1; + } } } @@ -5760,11 +5766,11 @@ fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { instance.y0 = @max(instance.y1, limit); } -fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { +fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.Layer { return bodyLayerIn(surface.bodyLayers(), col, row); } -fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const pardes.BodyLayer { +fn bodyLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; if (layer.rows > 0 and col >= r.x and col - r.x < r.w and row >= r.y and row - r.y < r.h) return layer; @@ -5788,7 +5794,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void { instance.y1 = limit; } -fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { +fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5851,11 +5857,11 @@ fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { return false; } -fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { +fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { const at = @as(usize, row) * cols + col; if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; for (layers) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or layer.viewport.y != row) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; if (col < x or col - x >= config.GUTTER) continue; const index = @as(usize, row) * cols + x; @@ -6607,7 +6613,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }}; + var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; @@ -7316,16 +7322,17 @@ fn buildOverlay( } }; if (transient_on) for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.col >= layer.cols) continue; + if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; const width: f32 = @floatFromInt(g.tagline_width); const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; - const x = left + @as(f32, @floatFromInt(cursor.col)) * width; + const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline))); + const y = layer.viewport.y + cursor.y; + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); + builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); }; if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); @@ -7506,7 +7513,7 @@ fn appendPaneChrome( // off from the text beneath it the way a tagline is: the same colour and // thickness along its bottom, running to the window edge when it does. for (surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; @@ -7528,21 +7535,25 @@ fn appendPaneChrome( fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (surface.tagLayers()) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - const y = layer.viewport.y; - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + // A mark on each of the tag's rows whose grip cell is blank: every + // row but the one with the mode's glyph. + for (0..layer.rows) |index| { + const y = layer.viewport.y + @as(u16, @intCast(index)); + if (x >= surface.cols or y >= surface.rows) continue; + const cell = &surface.cells[@as(usize, y) * surface.cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); + builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + } } } @@ -7553,8 +7564,8 @@ test "column grips stay unmarked while pane marks retain physical centering" { g.cell_h = 20; var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20 }; + surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; + surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; var vertices: [24]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); @@ -7927,7 +7938,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [24]pardes.Cell = @splat(.{}); - var layer: pardes.BodyLayer = .{ + var layer: pardes.Layer = .{ .viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 }, .cols = 2, .rows = 12, @@ -7967,7 +7978,7 @@ test "compact tag layers fill available width and clip at their physical edge" { g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [6]pardes.Cell = @splat(.{}); - const layer: pardes.TagLayer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .cells = &cells, .bg = .{ 20, 30, 40 } }; + const layer: pardes.Layer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .rows = 1, .cells = &cells, .bg = .{ 20, 30, 40 } }; var instances: [8]CellInstance = undefined; var next: u32 = 0; emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); diff --git a/src/look.zig b/src/look.zig index 8f9e15f2..258cf8f5 100644 --- a/src/look.zig +++ b/src/look.zig @@ -21,7 +21,7 @@ const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const pathUnder = pardes.pathUnder; diff --git a/src/macos.zig b/src/macos.zig index fce0a53e..f7e03cd4 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1861,9 +1861,9 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.TagLayer) void { +fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.Layer) void { for (layers) |layer| { - if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; + if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { @@ -1879,7 +1879,7 @@ fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cel fn tagCursorCovered(core: *const pardes.Pardes) bool { const cursor = core.surface.cursor orelse return false; for (core.surface.tag_layers) |layer| { - if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and + if (layer.rows != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and cursor.x - layer.viewport.x < layer.viewport.w) return true; } return false; @@ -2121,7 +2121,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], 11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom), @@ -2139,7 +2139,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell { const st = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &st.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &st.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source); @@ -3110,7 +3110,7 @@ test "mac tag layer ABI preserves logical capacity and physical grip" { logical[11].text[0] = 'Z'; logical[11].len = 1; logical[11].default = false; - core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } }; + core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .rows = 1, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .x = 11, .y = 0, .bar = true } }; defer core.surface.tag_layers[0] = .{}; try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0)); try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10)); diff --git a/src/pardes.zig b/src/pardes.zig index 6946e123..f8087bdb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ 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 MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); @@ -247,21 +248,17 @@ pub const Pane = panes.Pane; const Text = panes.Text; pub const MAX_PANES = 16; -pub const BodyLayer = @import("body_layer.zig").Layer; -pub const RowMetrics = @import("body_layer.zig").Metrics; -pub const TagLayer = @import("tag_layer.zig").Layer; -pub const TagHit = @import("tag_layer.zig").Hit; -pub const TagKind = @import("tag_layer.zig").Kind; -/// Panes, then columns, then the workspace bar, then each pane's notice bands. +pub const Layer = @import("Layer.zig"); +pub const RowMetrics = Layer.Metrics; +pub const TagHit = Layer.TagHit; +pub const TagKind = Layer.Kind; /// A tag shows at most this many lines; past them it is cut, like a line /// too long for its width. pub const MAX_TAG_ROWS = 8; -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (MAX_TAG_ROWS - 1); +/// Panes, then columns, then the workspace bar, then each pane's notice +/// bands: a band to a tag, however many lines it has. +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; -/// Where the layers for a pane tag's second and later lines start. -pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max; -/// ...and those of a column's (by index) or the workspace's (after them). -pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1); pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -787,7 +784,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y); try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y); @@ -5832,15 +5829,14 @@ pub const Pardes = struct { pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; + if (layer.rows == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; if (layer.kind == .pane) { if (layer.id >= p.panes.len) return null; const pane = p.panes[layer.id] orelse return null; if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; - // A taller tag has a layer per line; the point is on one of them. - return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; + return layer.tagHit(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; } return null; } @@ -5858,14 +5854,14 @@ pub const Pardes = struct { } pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { - const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; + const layer: Layer = .{ .kind = .pane, .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; - return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); + return layer.bodyHit(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); } return null; } @@ -6111,12 +6107,12 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch // A pixel shell draws the prompt from its band, in the tagline's pitch, // with the caret right after the marker's last glyph. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; try std.testing.expectEqual(row, band.viewport.y); const caret = band.cursor orelse return error.MissingBandCaret; try std.testing.expect(caret.bar); - try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme()); + try std.testing.expectEqualStrings("/", band.cells[caret.x - 1].grapheme()); // The chip covers only its own columns. Left of it is ordinary body, and // a sweep there still selects. @@ -6162,11 +6158,11 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(p.surface.tag_layers[0].cursor == null); // The band carries the answer and the caret, one cell back from its end. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; const caret = band.cursor orelse return error.MissingBandCaret; - try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); - try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); + try std.testing.expectEqualStrings("t", band.cells[caret.x - 1].grapheme()); + try std.testing.expectEqualStrings("w", band.cells[caret.x].grapheme()); const grid = p.surface.cursor orelse return error.MissingPromptCursor; try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y); diff --git a/src/surface.zig b/src/surface.zig index fec43e30..db7dd468 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -11,8 +11,7 @@ const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const image = @import("image.zig"); const MAX_PANES = pardes.MAX_PANES; -const BodyLayer = @import("body_layer.zig").Layer; -const TagLayer = @import("tag_layer.zig").Layer; +const Layer = @import("Layer.zig"); const MAX_TAG_LAYERS = pardes.MAX_TAG_LAYERS; const CellPixels = pardes.CellPixels; @@ -380,8 +379,8 @@ pub const Surface = struct { /// paint the quiet affordance can carry the hover bit through the ABI /// without every selection painter learning a new parameter. mark_hover: bool = false, - body_layers: [MAX_PANES]BodyLayer = @splat(.{}), - tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), + body_layers: [MAX_PANES]Layer = @splat(.{}), + tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, @@ -394,15 +393,15 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, - previous_body_layers: []const BodyLayer = &.{}, - previous_tag_layers: []const TagLayer = &.{}, + previous_body_layers: []const Layer = &.{}, + previous_tag_layers: []const Layer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, - pub fn tagLayers(s: *const Surface) []const TagLayer { + pub fn tagLayers(s: *const Surface) []const Layer { return &s.tag_layers; } - pub fn bodyLayers(s: *const Surface) []const BodyLayer { + pub fn bodyLayers(s: *const Surface) []const Layer { return &s.body_layers; } diff --git a/src/tag_layer.zig b/src/tag_layer.zig index 7cfe5bbf..213ebc07 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -1,83 +1,10 @@ -//! Tag text has its own horizontal pitch. Its viewport stays in physical -//! body-grid coordinates so compact text never changes pane hit targets. +//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one +//! that left its band back onto it. const std = @import("std"); const pardes = @import("pardes.zig"); - -/// `notice` is a pane's transient line -- a builtin's name, a pending leader -/// chord -- carried as a tagline-shaped band so it gets the tagline's height, -/// small-font metrics, band offset and border instead of being a body-grid row -/// wearing a tagline font. -pub const Kind = enum(u8) { workspace, column, pane, notice }; -pub const Hit = struct { - kind: Kind, - id: u16, - serial: u32, - col: u16, - /// Which line of a taller tag the layer draws. - line: u16 = 0, - pixel_x: f32, - pixel_y: f32, - metrics: pardes.RowMetrics, -}; -pub const Layer = struct { - kind: Kind = .workspace, - id: u16 = 0, - serial: u32 = 0, - /// A pane's tag taller than a line is drawn a layer to a line; this is - /// which one, counted from its first. - line: u16 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - bg: [3]u8 = .{ 0, 0, 0 }, - cells: []pardes.Cell = &.{}, - cursor: ?struct { col: u16, bar: bool = false } = null, - /// Vertical offset in rows (negative: above its viewport), for a band - /// that is moving into place. A pixel shell draws it offset and clipped - /// to the rows below the viewport's top; the grid has no such thing. - slide: f32 = 0, - /// How present a notice band is (1 = fully) while it fades up or - /// dissolves. `bg` and the cells are already blended by it; a pixel - /// shell uses it for what it draws around the band, like its rule. - fade: f32 = 1, - - pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { - return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Hit { - if (layer.cols == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| - if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - return .{ - .kind = layer.kind, - .id = layer.id, - .serial = layer.serial, - .line = layer.line, - .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))), - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "tag text capacity uses its own pitch and excludes the physical grip" { - const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90 }; - try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); - try std.testing.expect(layer.hitAt(119.99, 85, 10, 20, 6, 12) == null); - try std.testing.expectEqual(@as(u16, 0), layer.hitAt(120, 85, 10, 20, 6, 12).?.col); - try std.testing.expectEqual(@as(u16, 50), layer.hitAt(423, 85, 10, 20, 6, 12).?.col); - try std.testing.expect(layer.hitAt(500, 85, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); -} +const Layer = @import("Layer.zig"); +const Hit = Layer.TagHit; +const Kind = Layer.Kind; /// Whether two pointer hits name the same cell of the same tag line. pub fn sameCell(a: ?Hit, b: ?Hit) bool { @@ -92,13 +19,13 @@ pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, var point = supplied orelse return null; if (point.kind != kind or point.id != id) return null; if (clamp) for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue; + if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue; const bw: f32 = @floatFromInt(point.metrics.body_w); const bh: f32 = @floatFromInt(point.metrics.body_h); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh; break; }; return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; diff --git a/src/web.zig b/src/web.zig index 532e3794..ac0ecbbf 100644 --- a/src/web.zig +++ b/src/web.zig @@ -224,13 +224,13 @@ export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_a var hit: ?pardes.Mouse.BodyHit = null; for (&s.core.surface.body_layers) |*layer| { const point = source orelse break; - hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + hit = layer.bodyHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (hit != null) break; } var tag_hit: ?pardes.TagHit = null; for (&s.core.surface.tag_layers) |*layer| { const point = source orelse break; - tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + tag_hit = layer.tagHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (tag_hit != null) break; } mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit); @@ -341,6 +341,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_TAG_LAYERS) return 0; const layer = &s.core.surface.tag_layers[index]; + if (layer.rows == 0) return 0; return switch (field) { 0 => layer.cols, 1 => @intFromEnum(layer.kind), @@ -350,9 +351,12 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], + // A band has a row to each line of its tag: `cols * rows` cells. + 11 => layer.rows, + 12 => if (layer.cursor) |c| c.y else 65535, else => 0, }; } @@ -361,7 +365,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { const s = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &s.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &s.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source); diff --git a/src/web/app.mjs b/src/web/app.mjs index 280239d5..03829178 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -64,13 +64,16 @@ export class DomRenderer { if (!val(0)) continue; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const textInset = this.wasm.pardes_tag_text_inset?.() ?? gripCols; - if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { - const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; - for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(val(5)*cols+x,offset); - if (val(1) === 2) paneIndicators.add(val(5)*cols+val(4)-textInset); - } - for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { - if (val(5) < rows) tagCovered[val(5) * cols + x] = 1; + // A row to each line of a taller tag. + for (let y = val(5); y < val(5) + Math.max(1, val(11)); y++) { + if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { + const offset = this.wasm.pardes_tagline_band_offset(y,rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; + for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(y*cols+x,offset); + if (val(1) === 2) paneIndicators.add(y*cols+val(4)-textInset); + } + for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { + if (y < rows) tagCovered[y * cols + x] = 1; + } } } } @@ -118,27 +121,35 @@ export class DomRenderer { const nodes = []; const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight; for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { - const values = Array.from({length:11}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); - const cols = values[0]; + const layerValues = Array.from({length:13}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); + const cols = layerValues[0]; if (!cols) continue; const ptr = this.wasm.pardes_tag_layer_cells(i); if (!ptr) continue; - const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + // A row to each line of a taller tag, each drawn as a band of its own. + for (let line = 0; line < Math.max(1, layerValues[11]); line++) { + const key = i*64+line; + const values = layerValues.slice(0,11); + values[5] = layerValues[5] + line; + values[7] = 1; + values[8] = layerValues[12] === line ? layerValues[8] : 65535; + const bytes = new Uint8Array(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols); const gripCell = canonical[values[5]*this.cols+gripX]; const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1; const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":"); - const cached = this.tagNodes.get(i); + const cached = this.tagNodes.get(key); if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) { nodes.push(cached.node); if (cached.grip) nodes.push(cached.grip); continue; } - const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + const view = new DataView(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const layer = this.root.ownerDocument.createElement("div"); layer.dataset.tagLayer = String(i); + layer.dataset.tagLine = String(line); layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`; for (let col = 0; col < cols; col++) { const off = col*CELL_SIZE; @@ -171,9 +182,10 @@ export class DomRenderer { gripNode.append(mark); } } - this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); + this.tagNodes.set(key,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); nodes.push(layer); if (gripNode) nodes.push(gripNode); + } } if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes); } diff --git a/test/column_drag.zig b/test/column_drag.zig index feea1bf7..3aea4286 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -8,17 +8,17 @@ fn mouse(p: *Pardes, kind: pardes.Mouse.Kind, col: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = kind, .col = col, .row = pardes.TOPBAR_H } }); } -fn tagLayer(p: *Pardes, column: usize) !*pardes.TagLayer { +fn tagLayer(p: *Pardes, column: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == .column and layer.id == column) return layer; + if (layer.rows > 0 and layer.kind == .column and layer.id == column) return layer; } return error.MissingColumnTagLayer; } -fn headerMouse(layer: *const pardes.TagLayer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { +fn headerMouse(layer: *const pardes.Layer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(display_col)) + 0.5) * 5; const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } const Snapshot = struct { @@ -144,7 +144,7 @@ test "column grip is separate from compact text and the resize seam" { try std.testing.expectEqual(p.col_x[column], rect.x); try std.testing.expectEqual(rect.x + pardes.TAG_TEXT_INSET, (try tagLayer(p, column)).viewport.x); for (p.surface.tagLayers()) |pane_layer| { - if (pane_layer.cols > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) + if (pane_layer.rows > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) try std.testing.expectEqual((try tagLayer(p, column)).viewport.x, pane_layer.viewport.x); } const pane_tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; diff --git a/test/panes.zig b/test/panes.zig index 11fb750a..5e26cdd0 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -19,17 +19,17 @@ fn setTag(pane: *Pane, text: []const u8) !void { } const TagLayerTests = struct { - const Kind = @FieldType(pardes.TagLayer, "kind"); - fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.TagLayer { + const Kind = @FieldType(pardes.Layer, "kind"); + fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == kind and layer.id == id) return layer; + if (layer.rows > 0 and layer.kind == kind and layer.id == id) return layer; } return error.MissingTagLayer; } - fn mouse(layer: *const pardes.TagLayer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } test "compact tag layers render and edit Unicode text beyond body column capacity" { @@ -200,14 +200,16 @@ test "a tag with a newline in it is as tall as its lines, and each line is typed const body_y = p.bodyTop(pane, r); try std.testing.expectEqual(if (bottom) r.y else r.y + 2, body_y); try std.testing.expectEqualStrings("b", surface.at(r.x + config.GUTTER + panes.File.gutterWidth(pane), body_y).grapheme()); - // a layer to a line, for a pixel shell - var lines: usize = 0; + // one layer, a row to each line, for a pixel shell + var layers: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.id != 0) continue; - try std.testing.expectEqual(tag_y + layer.line, layer.viewport.y); - lines += 1; + if (layer.rows == 0 or layer.kind != .pane or layer.id != 0) continue; + try std.testing.expectEqual(tag_y, layer.viewport.y); + try std.testing.expectEqual(@as(u16, 2), layer.rows); + try std.testing.expectEqual(@as(u16, 2), layer.viewport.h); + layers += 1; } - try std.testing.expectEqual(@as(usize, 2), lines); + try std.testing.expectEqual(@as(usize, 1), layers); // a click on the second line puts the tag's cursor on it p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.{ .key = .{ .cp = ':' } }); @@ -541,11 +543,11 @@ const TagNavigationTests = struct { }; const BodyHitTests = struct { - fn mouse(layer: *const pardes.BodyLayer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const width: f32 = if (row < layer.context_rows) 8 else 10; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * width; const y = layer.rowTop(row, 20, 10) + layer.rowHeight(row, 20, 10) / 2; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.hitAt(x, y, 10, 20, 8, 10).? }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.bodyHit(x, y, 10, 20, 8, 10).? }; } test "compact TreeContext mouse follows source rows in reclaimed space" { @@ -673,7 +675,7 @@ const TreeContextTests = struct { const y = layer.rowTop(row, 20, 10) + 9.5; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + 5; // A one-pixel separator remains inside its existing header row. - try std.testing.expectEqual(row, layer.hitAt(x, y, 10, 20, 8, 10).?.row); + try std.testing.expectEqual(row, layer.bodyHit(x, y, 10, 20, 8, 10).?.row); } pane.scrollBy(-5); _ = try p.render(frame.allocator()); -- cgit v1.3 From 360b72ade0acfa655a3740b638a0cc8e23238a05 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:59:21 -0300 Subject: The GUI draws in tiers from the regions; pane chrome moves with its pane Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig. --- build.zig | 1 + docs/render-pipeline.md | 14 +- shaders/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.spv | Bin 0 -> 2100 bytes shaders/prebuilt/ui.frag.glsl | 3 + shaders/prebuilt/ui.frag.spv | Bin 4668 -> 5700 bytes shaders/prebuilt/ui.vert.glsl | 6 + shaders/prebuilt/ui.vert.spv | Bin 5468 -> 5612 bytes shaders/ui.frag.glsl | 3 + shaders/ui.vert.glsl | 6 + src/Presentation.zig | 5 + src/detached/client.zig | 4 +- src/detached/server.zig | 1 + src/detached/wire.zig | 41 +- src/draw.zig | 63 +- src/gui/gui.zig | 1476 +++++++++++++++++++------------------- src/pardes.zig | 1 + src/surface.zig | 40 +- test/gui-goldens.txt | 11 +- test/gui_golden.py | 18 +- 21 files changed, 978 insertions(+), 763 deletions(-) create mode 100644 shaders/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.spv (limited to 'src/surface.zig') diff --git a/build.zig b/build.zig index 666e792d..803f773d 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,7 @@ pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", + "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index a8b3f019..69645c96 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`) replaces the vertical transition's scissor. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | @@ -845,13 +845,21 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. + layer then. Carried forward from stage 8 (a GUI-only stage) to the hover + change after it, which also retires `mark_hover`: the affordance is a + core paint every shell and snapshot sees. 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The context-row path also still builds the body's text twice (a pre-pass with body_rows 0 decides the context rows, renderBody builds it again at - the layer's rows); the paint is single. Measure both there. + the layer's rows); the paint is single. Measure both there. Decided at + stage 8, measured (Tracy, 200x60, TreeContext on a scrolled source, + ReleaseFast): the pre-pass text build is 5 µs median and the layer's + copies 10 µs per context-row body per frame. Kept as they are: nothing in + the GUI reads a layerless body from anything but the grid, which is + exact, so a layer for every body would be ~10 µs a body a frame for no + pixel. ## 15. Questions for the user diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/prebuilt/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.spv b/shaders/prebuilt/decor.frag.spv new file mode 100644 index 00000000..7e162f5a Binary files /dev/null and b/shaders/prebuilt/decor.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 120438e7..033c10ab 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index 659049dd..619a9690 100644 Binary files a/shaders/prebuilt/ui.vert.spv and b/shaders/prebuilt/ui.vert.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/src/Presentation.zig b/src/Presentation.zig index e1f1b585..3b5bb3e0 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -44,6 +44,8 @@ pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), +previous_regions: [pardes.MAX_REGIONS]pardes.Region = undefined, +previous_nregions: u16 = 0, previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -346,6 +348,8 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } @memcpy(self.previous_cells, cells); + @memcpy(self.previous_regions[0..surface.nregions], surface.regionList()); + self.previous_nregions = surface.nregions; self.previous_cols = surface.cols; self.previous_rows = surface.rows; self.previous_valid = true; @@ -584,6 +588,7 @@ pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { s.previous_cells = self.previous_cells; s.previous_body_layers = &self.previous_body_layers; s.previous_tag_layers = &self.previous_tag_layers; + s.previous_regions = self.previous_regions[0..self.previous_nregions]; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( diff --git a/src/detached/client.zig b/src/detached/client.zig index 3347f609..9fa85fb4 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -107,6 +107,8 @@ pub const Client = struct { tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), /// The session's placed regions, as its last frame gave them. regions: std.ArrayListUnmanaged(pardes.Region) = .empty, + /// The palette its chrome is drawn in, as its last frame gave it. + chrome: pardes.Chrome = .{}, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -322,7 +324,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions, &c.chrome) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; diff --git a/src/detached/server.zig b/src/detached/server.zig index 46fb8fcf..9e18e125 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -760,6 +760,7 @@ pub const Session = struct { &surface.body_layers, &surface.tag_layers, surface.regionList(), + surface.chrome, ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 684c869b..32cd5229 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -169,8 +169,9 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void { regions.clearRetainingCapacity(); + chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -188,6 +189,8 @@ pub const Frame = struct { gpa.free(layer.cells); layer.* = .{}; } + regions.clearRetainingCapacity(); + chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); @@ -255,6 +258,15 @@ pub const Frame = struct { region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; regions.appendAssumeCapacity(region); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = &@field(chrome, field.name); + if (field.type == ?[3]u8) { + if (try r.getBool()) { + rgb.* = .{ 0, 0, 0 }; + for (&rgb.*.?) |*channel| channel.* = try r.getByte(); + } + } else for (rgb) |*channel| channel.* = try r.getByte(); + } try r.end(); } @@ -697,7 +709,7 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{}); } pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { @@ -708,12 +720,15 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg for (tags) |layer| if (layer.rows != 0) { n += 28 + layer.cells.len * cell_max; }; - return n + regions.len * region_len; + return n + regions.len * region_len + chrome_len; } const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; +/// The palette: each colour three bytes, a theme's optional ones a flag +/// before them. +const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -793,6 +808,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putByte(region.notice); try w.putU16(region.clip); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = @field(chrome, field.name); + if (field.type == ?[3]u8) { + try w.putBool(rgb != null); + if (rgb) |value| for (value) |channel| try w.putByte(channel); + } else for (rgb) |channel| try w.putByte(channel); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1871,7 +1893,8 @@ test "detached wire carries compact body rows and exact pointer metrics" { const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); @@ -1881,8 +1904,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try frame.apply(&cells); var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; defer regions.deinit(gpa); - try frame.applyLayers(gpa, &decoded, &tags, ®ions); + var chrome: pardes.Chrome = .{}; + try frame.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqualDeep(placed[0..], regions.items); + try std.testing.expectEqualDeep(palette, chrome); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1908,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions, &chrome)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); @@ -1922,6 +1947,6 @@ test "detached wire carries compact body rows and exact pointer metrics" { const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags, ®ions); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7cfe7554..4242a6e7 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -47,14 +47,7 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; /// 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 } }); + 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); @@ -64,12 +57,12 @@ pub fn place(p: *Pardes, s: *Surface) void { 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 } }); + 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; - 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 } }); + 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. @@ -93,7 +86,7 @@ pub fn place(p: *Pardes, s: *Surface) void { rail.thumb_y = @intCast(@min(pos, track_h)); rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); } - add(s, rail); + 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. @@ -106,7 +99,7 @@ pub fn place(p: *Pardes, s: *Surface) void { 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 } }); + 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(); @@ -114,11 +107,16 @@ pub fn place(p: *Pardes, s: *Surface) void { 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 } }); + 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) - add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); + 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: { @@ -176,8 +174,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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; @@ -203,16 +213,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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) { - var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + 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 } }); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; @@ -232,13 +246,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // 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) continue; + 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| { @@ -247,6 +268,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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 } }); } }, .none, .select => if (p.pointer_inside) { @@ -256,6 +279,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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| { @@ -264,6 +288,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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| { @@ -272,6 +297,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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 } }); } }, } @@ -309,6 +335,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { 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(), "╮", .{}); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ff99bd39..35fbc99d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -21,6 +21,7 @@ const c_heap = @import("c_heap"); const ninep_io = @import("../9p_io.zig"); const detached_client = @import("../detached/client.zig"); +const tracy = @import("../tracy.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @@ -37,6 +38,7 @@ const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = @embedFile("ui.vert.spv"); const frag_spv = @embedFile("ui.frag.spv"); +const decor_frag_spv = @embedFile("decor.frag.spv"); const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); @@ -443,6 +445,11 @@ const CellInstance = extern struct { progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, + /// A clip rectangle in target pixels; x1 < x0 is none (ui.vert.glsl). + clip_x0: f32 = 0, + clip_y0: f32 = 0, + clip_x1: f32 = -1, + clip_y1: f32 = -1, }; const OverlayVertex = extern struct { @@ -545,7 +552,8 @@ test "saved native placement retains identity and geometry but no producer bytes } test "shader instance ABI carries aligned transition vectors" { - try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 120), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 104), @offsetOf(CellInstance, "clip_x0")); try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect")); @@ -560,12 +568,17 @@ test "shader instance ABI carries aligned transition vectors" { .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; - const plan = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 4), plan.len); - try std.testing.expect(plan.batches[0].track == null); - try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane); - try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane); - try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane); + // Tier 0, the tracks as the core ordered them, then tiers 4 and 5. + const groups = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 7), groups.len); + try std.testing.expect(groups.items[0].track == null); + try std.testing.expectEqual(@as(u8, 0), groups.items[1].track.?.pane); + try std.testing.expectEqual(@as(u8, 1), groups.items[2].track.?.pane); + try std.testing.expectEqual(@as(u8, 2), groups.items[3].track.?.pane); + try std.testing.expectEqual(@as(usize, 4), groups.notices); + try std.testing.expectEqual(@as(usize, 5), groups.guides); + try std.testing.expectEqual(@as(usize, 6), groups.ink); + for ([_]usize{ groups.notices, groups.guides, groups.ink }) |index| try std.testing.expect(groups.items[index].track == null); } test "GUI paint plan snaps history effects without a frozen grid" { @@ -573,11 +586,11 @@ test "GUI paint plan snaps history effects without a frozen grid" { .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; - try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); - const ready = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(@as(usize, 0), makeGroups(&tracks, false).tracks); + const ready = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 2), ready.tracks); + try std.testing.expectEqual(pardes.animation.Phase.opening, ready.items[1].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.closing, ready.items[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { @@ -588,10 +601,10 @@ test "closing tombstone overlays but never owns canonical cells" { .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; - const plan = makePaintPlan(&.{closing}, true); - try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); - try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase); + const groups = makeGroups(&.{closing}, true); + try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); + try std.testing.expectEqual(@as(usize, 1), groups.tracks); + try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { @@ -1745,6 +1758,7 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -1764,9 +1778,11 @@ const Gui = struct { native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty, prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, - /// renderFrame's cover of the grid by layers (coverLayers): this frame's - /// layers, then the layers a panel transition is leaving. - layer_cover: std.ArrayListUnmanaged(bool) = .empty, + /// renderFrame's cover of the grid (coverFrame): this frame's, then the + /// frame a panel transition is leaving. + cover: std.ArrayListUnmanaged(Cover) = .empty, + /// renderFrame's decor instances, group by group (buildDecor). + decor: std.ArrayListUnmanaged(CellInstance) = .empty, font: *c.UIFont, font_bytes: []u8 = &.{}, @@ -1996,47 +2012,127 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { const boxContains = pardes.animation.Box.contains; -const PaintBatch = struct { +/// What one tier draws, or one track in tiers 2 and 3 (docs/render-pipeline.md +/// §3.4), in this order: its cells, its images, then its decor. +const Group = struct { track: ?pardes.animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, image_count: u32 = 0, + decor_start: u32 = 0, + decor_count: u32 = 0, }; -const PaintPlan = struct { - batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), - len: usize = 1, +/// The frame's draw groups, bottom to top: tier 0 (the page and the panes +/// standing still), a group for each track in paint order (tier 2 moving and +/// opening, tier 3 closing), tier 4 (floating: the notices, then the guides +/// and the debug box, which the core paints over them) and tier 5 (ink: the +/// bar cursors; the pet and the touch overlay draw after it). Tier 1 is the +/// focus lift, which does not exist yet. +const Groups = struct { + items: [pardes.MAX_PANES * 2 + 4]Group = @splat(.{}), + /// Groups 1 through `tracks` are the tracks'. + tracks: usize = 0, + notices: usize = 1, + guides: usize = 2, + ink: usize = 3, + len: usize = 4, }; -fn makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan { - var plan: PaintPlan = .{}; - for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| { - for (tracks) |track| { - if (!track.active() or track.phase != phase) continue; - if (track.effect.needsPreviousGrid() and !has_diff) continue; - std.debug.assert(plan.len < plan.batches.len); - plan.batches[plan.len].track = track; - plan.len += 1; - } +fn makeGroups(tracks: []const pardes.animation.Track, has_diff: bool) Groups { + var groups: Groups = .{}; + for (tracks) |track| { + if (!track.active()) continue; + if (track.effect.needsPreviousGrid() and !has_diff) continue; + std.debug.assert(groups.tracks < pardes.MAX_PANES * 2); + groups.tracks += 1; + groups.items[groups.tracks].track = track; } - return plan; + groups.notices = groups.tracks + 1; + groups.guides = groups.tracks + 2; + groups.ink = groups.tracks + 3; + groups.len = groups.tracks + 4; + return groups; } -fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| { - if (batch.track.?.phase == .closing) continue; - if (boxContains(batch.track.?.to, col, row)) return index; +/// The group of the track whose pane covers this cell, else tier 0. +fn groupAt(groups: *const Groups, col: u16, row: u16) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + if (group.track.?.phase == .closing) continue; + if (boxContains(group.track.?.to, col, row)) return index; } return 0; } -fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| - if (batch.track.?.serial == serial) return index; +fn groupOf(groups: *const Groups, serial: u32) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| + if (group.track.?.serial == serial) return index; return 0; } +/// What draws a grid cell, marked once a frame from the layers and the +/// regions (coverFrame), so the cell loops read it rather than search. +const Cover = packed struct(u16) { + /// A layer draws it; the grid's copy is not drawn. + layer: bool = false, + /// Under a guide or the debug box: drawn from the grid over the panes. + floating: bool = false, + /// In a pane's grip, `grip_offset` cells from its left: the grip's + /// glyph is the cell that far to the left. + grip: bool = false, + grip_offset: u4 = 0, + /// In the focused pane's or column's tag rows. + focus: bool = false, + /// At a column's left edge, where a tag's anchor is inset. + anchor: bool = false, + _: u7 = 0, +}; + +/// Marks the grid cells a body or tag layer covers, which the layers paint +/// themselves, and what the regions say of the rest. Filling each rectangle +/// once replaces scanning every layer for every cell, which was a third of +/// the gui's CPU on a full screen of text. +fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer, regions: []const pardes.Region) void { + @memset(cover, .{}); + const mark = struct { + fn rect(into: []Cover, width: u16, height: u16, r: pardes.Rect, comptime field: []const u8, value: anytype) void { + const right = @min(width, @as(u32, r.x) + r.w); + if (r.x >= right) return; + var y: u32 = r.y; + while (y < @min(height, @as(u32, r.y) + r.h)) : (y += 1) + for (into[y * width ..][r.x..right]) |*cell| { + @field(cell, field) = value; + }; + } + }; + for (body_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, layer.viewport, "layer", true); + } + for (tag_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, .{ .x = layer.viewport.x, .y = layer.viewport.y, .w = layer.viewport.w, .h = layer.rows }, "layer", true); + } + // The column bar's rows, which the focused column's tint spans. + var bar: ?pardes.Rect = null; + for (regions) |region| { + if (region.kind == .column_grip) bar = region.rect; + } + for (regions) |region| switch (region.kind) { + .grip => { + mark.rect(cover, cols, rows, region.rect, "grip", true); + for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); + if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + }, + .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), + .column => { + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = 0, .w = 1, .h = rows }, "anchor", true); + if (region.active) if (bar) |b| mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = b.y, .w = region.rect.w, .h = b.h }, "focus", true); + }, + .guide, .debug => mark.rect(cover, cols, rows, region.rect, "floating", true), + else => {}, + }; +} + fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); @@ -2101,6 +2197,15 @@ fn setTransitionFields( instance.progress_bits = @bitCast(active.amount()); instance.serial = active.serial; instance.cell_coord = cell_coord; + // A vertical transition shows its pane only inside the box it grows + // into, in whole pixels. + if (@hasField(@TypeOf(instance.*), "clip_x0") and active.effect == .vertical) { + const box = active.contentBox(); + instance.clip_x0 = std.math.clamp(@floor(box.x * layout.w), 0, win_w); + instance.clip_y0 = std.math.clamp(@floor(box.y * layout.h), 0, win_h); + instance.clip_x1 = std.math.clamp(@ceil((box.x + box.w) * layout.w), instance.clip_x0, win_w); + instance.clip_y1 = std.math.clamp(@ceil((box.y + box.h) * layout.h), instance.clip_y0, win_h); + } } pub const run = runNative; @@ -2276,7 +2381,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; - const pipeline = try makePipeline(device, swapchain_format); + const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend()); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(device, swapchain_format); const crt_pipeline = try makeCrtPipeline(device, swapchain_format); @@ -2306,6 +2412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, @@ -2343,6 +2450,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u // regrown to; the image ones are released below. defer { c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); + c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -2368,7 +2476,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); - g.layer_cover.deinit(gpa); + g.cover.deinit(gpa); + g.decor.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2479,6 +2588,7 @@ fn localSession( .watches = &watches, .fs = fs, .test_mode = test_mode, + .test_clock = if (test_mode) host_io.testClock() else null, }; defer shell.shutdownPtys(); const host = shell.host(); @@ -2668,24 +2778,12 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { - var surface: pardes.Surface = .{ - .cols = client.cols, - .rows = client.rows, - .cells = client.grid.items, - .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, - .pointer_shape = client.pointer_shape, - .body_layers = client.body_layers, - .tag_layers = client.tag_layers, - }; - const chrome = frameChromeBg(&surface); + var surface = attachedSurface(client); _ = renderFrame( g, gpa, null, &surface, - null, - chrome, - chrome, .{}, false, ) catch |err| blk: { @@ -2694,17 +2792,67 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { - if (surface.rows == 0 or surface.cols == 0) return bg_default; - for (surface.cells[0..surface.cols]) |cell| { - if (cell.default or cell.style.font_role != .tagline) continue; - return switch (cell.style.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - } - return bg_default; +/// The session's frame as a surface: its cells, layers, regions and palette, +/// so its chrome is drawn from them as a local one's is. +fn attachedSurface(client: *const detached_client.Client) pardes.Surface { + var surface: pardes.Surface = .{ + .cols = client.cols, + .rows = client.rows, + .cells = client.grid.items, + .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, + .pointer_shape = client.pointer_shape, + .body_layers = client.body_layers, + .tag_layers = client.tag_layers, + .chrome = client.chrome, + }; + const placed = client.regions.items[0..@min(client.regions.items.len, surface.regions.len)]; + @memcpy(surface.regions[0..placed.len], placed); + surface.nregions = @intCast(placed.len); + return surface; +} + +test "an attached frontend draws the session's chrome from its frame alone" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + // A gui-shaped core, whose tags and notices are layers. + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + core.settings.focus_tint = true; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice with a rule under it")); + pardes.test_api.sync(core); + // Past its slide into place, where its rule shows. + for (0..30) |_| core.update(.tick); + const s = try core.render(core.scratch.allocator()); + + const wire = @import("../detached/wire.zig"); + var client: detached_client.Client = .{ .gpa = gpa }; + defer client.deinit(); + var welcome: [64]u8 = undefined; + try client.in.appendSlice(gpa, try wire.encodeServer(&welcome, .{ .welcome = .{ .slot = 0, .cols = s.cols, .rows = s.rows } })); + const out = try gpa.alloc(u8, wire.frameBound(s.cols, s.rows) + wire.layersBound(&s.body_layers, &s.tag_layers, s.regionList())); + defer gpa.free(out); + try client.in.appendSlice(gpa, try wire.encodeFrameLayers(out, s.cols, s.rows, null, s.pointer_shape, s.cells, &.{}, &s.body_layers, &s.tag_layers, s.regionList(), s.chrome)); + _ = try client.next(); + _ = try client.next(); + const attached = attachedSurface(&client); + + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const local = try gpa.dupe(CellInstance, g.decor.items); + defer gpa.free(local); + try std.testing.expect(local.len > 8); + try std.testing.expect(groups.items[groups.notices].decor_count != 0); + try buildDecor(&g, gpa, &attached, &groups, fixedCellLayout(&g), 800, 480, true); + try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(local), std.mem.sliceAsBytes(g.decor.items)); } fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { @@ -3708,6 +3856,8 @@ const Shell = struct { saw_event: bool = false, dump_err: ?anyerror = null, retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. + test_clock: ?u64 = null, fn reap(s: *Shell) void { const now = shellClock(); @@ -3898,13 +4048,31 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); + // Idle is asleep until an event: SDL's own, and the user events the + // shell's queue raises for a terminal's output, a completion, a file + // system request. What the shell moves or times itself polls every + // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, + // a retired shell waiting to be killed, a present to retry. A + // minimized window sleeps through the core's animation too: nothing + // shows it, and it is caught up at once when the window is back. + const retiring = for (s.retired_shells) |child| { + if (child.kill_at != 0) break true; + } else for (s.ptys) |slot| { + if (slot) |pt| if (pt.kill_at != 0) break true; + } else false; + const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring; + const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: // waiting here too would make every tick cost two frames. - const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); + // The core's wake ran out: a virtual clock moves exactly there. + if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + }; if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on @@ -4044,9 +4212,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { s.gpa, core, frame, - core.theme().bg, - config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border, - core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { @@ -4078,8 +4243,8 @@ fn postPresent(ctx: ?*anyopaque) void { s.core.acknowledgePanelPresentation(frame.panelTracks()); } -fn guiNow(_: ?*anyopaque) u64 { - return c.SDL_GetTicksNS(); +fn guiNow(ctx: ?*anyopaque) u64 { + return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4846,7 +5011,7 @@ fn prepareNativeImages( gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, - plan: *PaintPlan, + groups: *Groups, sw: u32, sh: u32, ) bool { @@ -4872,9 +5037,9 @@ fn prepareNativeImages( } g.prepared_images.clearRetainingCapacity(); - for (plan.batches[0..plan.len], 0..) |*batch, batch_index| { - batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - const track = batch.track; + for (groups.items[0..groups.len], 0..) |*group, group_index| { + group.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; + const track = group.track; if (track) |active| { if (active.phase == .closing) { @@ -4894,7 +5059,7 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or - paintBatchForSerial(plan, current.serial) != batch_index) continue; + groupOf(groups, current.serial) != group_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { .dissolve => if (snapshotContainsPlacement(g, current)) null else active, @@ -4903,7 +5068,7 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, current_track, null, false); } } - if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { + if (group_index == 0) for (groups.items[1..][0..groups.tracks]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; for (g.presented_images.items) |saved| { @@ -4912,7 +5077,7 @@ fn prepareNativeImages( } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - batch.image_count = image_end - batch.image_start; + group.image_count = image_end - group.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; @@ -4981,11 +5146,10 @@ fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, - plan: *const PaintPlan, - batch_index: usize, + batch: Group, + whole: c.SDL_Rect, ) void { const pass = rp orelse return; - const batch = plan.batches[batch_index]; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); const max_w = @as(u32, surface.cols) * g.cell_w; const max_h = @as(u32, surface.rows) * g.cell_h; @@ -5004,12 +5168,8 @@ fn drawNativeImagesGpu( idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); - const whole = c.SDL_Rect{ - .x = 0, - .y = 0, - .w = @intCast(@as(u32, surface.cols) * g.cell_w), - .h = @intCast(@as(u32, surface.rows) * g.cell_h), - }; + // Back to the whole target: what draws after a picture may run on past + // the grid, as a rule and a band do. c.SDL_SetGPUScissor(pass, &whole); } @@ -5018,9 +5178,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - theme_bg: ?[3]u8, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { @@ -5084,45 +5241,39 @@ fn renderFrame( }; const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target; + // The frame's CPU side: groups, decor, cover and every instance, up to + // their upload. + const build_zone = tracy.zone(@src(), "gui frame build"); + var building = true; + defer if (building) build_zone.end(); const layout = fixedCellLayout(g); - var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - var overlay_count = buildOverlay( - g, - core, - surface, - layout, - sw, - sh, - topbar_pane_border_rgb, - tagline_rgb, - paint_plan.len == 1, - debug_on, - ); + const chrome = &surface.chrome; + var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); + // Cursors stand still while panes move, as they always have. + const cursors_shown = groups.tracks == 0; + var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on); if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; - if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { + if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) { g.prepared_images.clearRetainingCapacity(); - for (paint_plan.batches[0..paint_plan.len]) |*batch| { - batch.image_start = 0; - batch.image_count = 0; + for (groups.items[0..groups.len]) |*group| { + group.image_start = 0; + group.image_count = 0; } } const cells: u32 = @as(u32, surface.cols) * surface.rows; - try g.layer_cover.resize(gpa, @as(usize, cells) * 2); - const covered = g.layer_cover.items[0..cells]; - coverLayers(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers()); + try g.cover.resize(gpa, @as(usize, cells) * 2); + const covered = g.cover.items[0..cells]; + coverFrame(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers(), surface.regionList()); // Only a panel transition paints the grid under the layers it leaves. - const previous_covered = g.layer_cover.items[cells..]; - if (paint_plan.len > 1) - coverLayers(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers); + const previous_covered = g.cover.items[cells..]; + if (groups.tracks != 0) + coverFrame(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers, surface.previous_regions); + try buildDecor(g, gpa, surface, &groups, layout, win_w, win_h, cursors_shown); - const page = ground(theme_bg, g.transparent); + const page = ground(chrome.page, g.transparent); const background_opacity = backgroundOpacity(g.applied_window_opacity); - const tagline_base = pardes.Cell{ - .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, - .default = false, - }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ @@ -5139,28 +5290,21 @@ fn renderFrame( while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - const logical_idx: u32 = @as(u32, row) * surface.cols + col; + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + const track = groups.items[group_index].track; + const data_effect = if (track) |active| active.effect == .dissolve else false; + const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); const instance_count = if (data_diff) - cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row)) + cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at]) else - cellInstanceCount(surface.at(col, row)); - paint_plan.batches[batch_index].cell_count = std.math.add( - u32, - paint_plan.batches[batch_index].cell_count, - instance_count, - ) catch return error.GpuCapacity; + cellInstanceCount(&surface.cells[at]); + groups.items[group_index].cell_count = std.math.add(u32, groups.items[group_index].cell_count, instance_count) catch return error.GpuCapacity; } } - for (paint_plan.batches[1..paint_plan.len]) |*batch| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks]) |*group| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5168,33 +5312,29 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination = if (duplicate_under) &paint_plan.batches[0] else batch; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - destination.cell_count = std.math.add( - u32, - destination.cell_count, - cellInstanceCount(&surface.previous_cells[logical_idx]), - ) catch return error.GpuCapacity; + const at = @as(usize, row) * surface.cols + col; + if (previous_covered[at].layer) continue; + const destination = if (duplicate_under) &groups.items[0] else group; + destination.cell_count = std.math.add(u32, destination.cell_count, cellInstanceCount(&surface.previous_cells[at])) catch return error.GpuCapacity; } } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; + const group = &groups.items[groupAt(&groups, layer.viewport.x, layer.viewport.y)]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; - batch.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); + group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; + const destination = &groups.items[if (under) 0 else index]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; @@ -5204,28 +5344,28 @@ fn renderFrame( } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + const group_index = if (layer.kind == .notice) groups.notices else groupAt(&groups, layer.viewport.x, layer.viewport.y); + groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } - var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); + var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0); var cell_total: u32 = 0; - for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| { - batch.cell_start = cell_total; - cell_next[batch_index] = cell_total; - cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; - } - if (cell_total != 0) { - const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; + for (groups.items[0..groups.len], 0..) |*group, index| { + group.cell_start = cell_total; + cell_next[index] = cell_total; + cell_total = std.math.add(u32, cell_total, group.cell_count) catch return error.GpuCapacity; + } + const decor_len: u32 = @intCast(g.decor.items.len); + if (cell_total != 0 or decor_len != 0) { + const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { @@ -5239,45 +5379,40 @@ fn renderFrame( row = 0; while (row < surface.rows) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const line_cover = covered[@as(usize, row) * surface.cols ..][0..surface.cols]; const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - if (data_diff) { - emitSurfaceCell( - g, - core, - instances, - &cell_next[batch_index], - col, - row, - layout, - win_w, - win_h, - track, - surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], - col, - null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), - true, - false, - page, - ); - } + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + const track = groups.items[group_index].track; + const data_effect = if (track) |active| active.effect == .dissolve else false; + const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); + if (data_diff) emitSurfaceCell( + g, + chrome, + instances, + &cell_next[group_index], + col, + row, + layout, + win_w, + win_h, + track, + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], + col, + null, + true, + false, + page, + ); emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[batch_index], + &cell_next[group_index], col, row, layout, @@ -5285,12 +5420,11 @@ fn renderFrame( win_h, if (data_effect and !data_diff) null else track, line, + line_cover, col, line_cursor, - &tagline_base, - paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row), false, - paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -5299,36 +5433,36 @@ fn renderFrame( // is a tag layer, and instances paint in emission order. for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); + const group_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + emitBodyLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[group_index].track, page, cursors_shown, false, chrome.topbar_rule); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); + const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + // A notice floats over the panes, moving with its own. + const group_index = if (layer.kind == .notice) groups.notices else track_index; + emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); + emitTagLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitBodyLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, topbar_pane_border_rgb); + emitBodyLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, chrome.topbar_rule); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5336,13 +5470,12 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination: usize = if (duplicate_under) 0 else batch_index; + if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue; emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[destination], + &cell_next[if (duplicate_under) 0 else index], col, row, layout, @@ -5350,10 +5483,9 @@ fn renderFrame( win_h, if (duplicate_under) null else track, surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), false, false, page, @@ -5361,57 +5493,56 @@ fn renderFrame( } } } - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| - std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count); + for (groups.items[0..groups.len], 0..) |group, index| + std.debug.assert(cell_next[index] == group.cell_start + group.cell_count); shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); + @memcpy(instances[cell_total + shifted ..][0..decor_len], g.decor.items); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); if (g.atlas_dirty_top < g.atlas_dirty_bottom) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; - const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted) * @sizeOf(CellInstance) }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted + decor_len) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); } + build_zone.end(); + building = false; const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); pushLayerOpacity(cmd, rp, background_opacity); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { - const has_shifted = batch_index == 0 and shifted != 0; - if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) { + const decor_base = cell_total + shifted; + for (groups.items[0..groups.len], 0..) |group, index| { + const has_shifted = index == 0 and shifted != 0; + if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - if (batch.cell_count != 0) { - const panel_clipped = if (batch.track) |active| active.effect == .vertical else false; - if (panel_clipped) { - const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh); - c.SDL_SetGPUScissor(rp, &clip); - } - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = batch.cell_start * @sizeOf(CellInstance), - }; + if (group.cell_count != 0) { + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = group.cell_start * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0); - if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); + c.SDL_DrawGPUPrimitives(rp, 6, group.cell_count, 0, 0); } if (has_shifted) { const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = cell_total * @sizeOf(CellInstance), - }; + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); c.SDL_SetGPUScissor(rp, &whole); } } - if (batch.image_count != 0) - drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index); + if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); + if (group.decor_count != 0 and g.vbuf != null) { + c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); + // Ink keeps its colour at any WindowOpacity. + pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity); + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + } } drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); @@ -5455,23 +5586,23 @@ fn renderFrame( } if (g.capture) { command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh); + try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } if (g.soft_present) { command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); } return submitted; } @@ -5487,24 +5618,6 @@ const ResolvedCell = struct { const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; -fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 { - if (top == 0 or surface.rows <= top) return null; - const height = topbarPaneBorderPixels(cell_h, tagline_h); - if (height == 0) return null; - const base = @as(usize, top) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return height; - return null; -} - -fn bottomTaglinePresent(surface: *const pardes.Surface) bool { - if (surface.rows == 0) return false; - const base = @as(usize, surface.rows - 1) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return true; - return false; -} - test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; @@ -5587,40 +5700,6 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 { return if (cellFontRole(cell) == .tagline) 2 else 1; } -fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { - const chrome = core.chromeTheme(); - if (!core.settings.focus_tint) return chrome.tag_bg; - if (row >= core.topBarHeight() and row < core.topBarHeight() + core.columnBarHeight()) { - for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| { - if (id != core.active) continue; - const x = core.col_x[column]; - if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg; - }; - } - const r = core.rects[core.active]; - const pane = core.panes[core.active] orelse return chrome.tag_bg; - const tag_y = core.tagTop(pane, r); - return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; -} - -test "compact column and pane underlays share focus tint without tinting the global tag" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - core.active = 1; - const active = core.rects[1]; - const inactive = core.rects[0]; - const chrome = core.chromeTheme(); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y)); - core.settings.focus_tint = false; - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); -} - fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; @@ -5629,34 +5708,10 @@ fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.L return null; } -/// Marks the grid cells a body or tag layer covers, which the layers paint -/// themselves: the grid's cell loops skip them. Filling each layer's rectangle -/// once replaces scanning every layer for every cell, which was a third of the -/// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers its rows, and a -/// viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { - @memset(cover, false); - for (body_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + r.h)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } - for (tag_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } -} - -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { +fn expectCoverIsScan(cover: []const Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; - try std.testing.expectEqual(scan, cover[row * cols + col]); + try std.testing.expectEqual(scan, cover[row * cols + col].layer); }; } @@ -5670,7 +5725,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); pardes.layout.insert(core, 1, 1, 2); pardes.layout.insert(core, 0, 1, 3); - var cover: [97 * 31]bool = undefined; + var cover: [97 * 31]Cover = undefined; var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; @@ -5683,9 +5738,20 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows); - @memset(&cover, true); // what an earlier frame left - coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + @memset(&cover, .{ .layer = true, .floating = true, .grip = true, .focus = true }); // what an earlier frame left + coverFrame(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + // A grip's cells know their way to its glyph, and the focused + // pane's tag rows are the focused ones. + for (s.regionList()) |region| { + if (region.kind != .grip) continue; + for (0..region.rect.w) |offset| { + const cell = cover[@as(usize, region.rect.y) * s.cols + region.rect.x + offset]; + try std.testing.expect(cell.grip); + try std.testing.expectEqual(offset, cell.grip_offset); + try std.testing.expectEqual(region.owner == core.active, cell.focus); + } + } for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5705,18 +5771,18 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand const random = prng.random(); var body: [pardes.MAX_PANES]pardes.Layer = undefined; var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; - var grid: [64 * 40]bool = undefined; + var grid: [64 * 40]Cover = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); const rows = random.intRangeAtMost(u16, 1, 40); const empty = random.intRangeAtMost(u8, 2, 5); // about one layer in `empty` is empty for (&body) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; - for (&tag) |*layer| layer.* = .{ .cols = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; + for (&tag) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; const bodies = body[0..random.uintAtMost(usize, body.len)]; const tags = tag[0..random.uintAtMost(usize, tag.len)]; const cells = grid[0 .. @as(usize, cols) * rows]; - @memset(cells, random.boolean()); - coverLayers(cells, cols, rows, bodies, tags); + @memset(cells, .{ .layer = random.boolean() }); + coverFrame(cells, cols, rows, bodies, tags, &.{}); try expectCoverIsScan(cells, cols, rows, bodies, tags); } } @@ -5844,42 +5910,23 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const } } -const GripCell = struct { cell: *const pardes.Cell, offset: u16 }; - -fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { +/// A dissolving cell changes with its grip's glyph, which is the cell its +/// grip offset to the left, in this frame's grip or the last one's. +fn gripCellChanged(surface: *const pardes.Surface, covered: []const Cover, previous_covered: []const Cover, col: u16, row: u16) bool { if (surface.panelCellChanged(col, row)) return true; - if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } - if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } + const at = @as(usize, row) * surface.cols + col; + for ([_]Cover{ covered[at], previous_covered[at] }) |cover| + if (cover.grip and surface.panelCellChanged(col -| cover.grip_offset, row)) return true; return false; } -fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { - const at = @as(usize, row) * cols + col; - if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; - for (layers) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - if (col < x or col - x >= config.GUTTER) continue; - const index = @as(usize, row) * cols + x; - if (index >= cells.len) return null; - const cell = &cells[index]; - if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null; - return .{ .cell = cell, .offset = col - x }; - } - return null; -} - fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } fn emitSurfaceCell( g: *Gui, - core: ?*const pardes.Pardes, + chrome: *const pardes.Chrome, instances: [*]CellInstance, next: *u32, col: u16, @@ -5889,18 +5936,20 @@ fn emitSurfaceCell( win_h: f32, track: ?pardes.animation.Track, line: []const pardes.Cell, + line_cover: []const Cover, at: usize, cursor: ?usize, - tagline_base: *const pardes.Cell, - grip: ?GripCell, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = cellFontRole(&line[at]); if (role == .tagline) { - var band_base = tagline_base.*; - if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; + const here = line_cover[at]; + const band_base: pardes.Cell = .{ + .style = .{ .bg = .{ .rgb = if (here.focus) chrome.tag_focus_bg else chrome.tag_bg }, .font_role = .tagline }, + .default = false, + }; // The smaller font changes the ink, not the chrome's coverage. Painting // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. @@ -5908,27 +5957,32 @@ fn emitSurfaceCell( next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A grip's glyph is the cell at its left edge, and a blank one is + // no glyph: its marks are decor. + const glyph = &line[at - @min(here.grip_offset, at)]; + const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. - const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; - if (grip) |indicator| { + const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. - const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell)); - const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true }); + const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); + const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; - const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page); + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; - instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w; + instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (grip == null or grip.?.offset == 0)) { + if (inset > 0 and (!grip or offset == 0)) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -5940,8 +5994,7 @@ fn emitSurfaceCell( } test "small tagline fonts leave no background gutters between adjacent tags" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); + const chrome: pardes.Chrome = .{}; var g: Gui = undefined; g.words = .{}; g.cell_w = 10; @@ -5954,7 +6007,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. - emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); @@ -5967,12 +6020,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" { // Column 0 is an anchor's edge: its ink is inset by the band's margin // (2 px here) and its underlay still covers the whole cell. next = 0; - emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{ .anchor = true }}, 0, null, false, false, Ground.opaqueRgb(bg_default)); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; - emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001); } @@ -6086,24 +6139,25 @@ test "insert cursor overlays without replacing the character beneath it" { }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); - var vertices: [6]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ - .vertices = &vertices, - .win_w = 100, - .win_h = 100, - }; - addCursorBar(&builder, 2, 3, true, 8, .{ - .x_off = 0, - .y_off = 0, - .w = 10, - .h = 20, - }, .{ 32, 48, 64 }); - try std.testing.expectEqual(@as(usize, 6), builder.len); - try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001); - for (vertices) |vertex| try std.testing.expectEqual(@as(u32, 1), vertex.ink); - try std.testing.expect(!builder.ink); + // A bar at a tag cell: tagline-tall, centered in its row, in the + // cell's ink, drawn as decor over it. + var cells: [25]pardes.Cell = @splat(.{}); + cells[3 * 5 + 2] = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 32, 48, 64 } }, .font_role = .tagline } }; + const surface: pardes.Surface = .{ .cols = 5, .rows = 5, .cells = &cells, .cursor = .{ .x = 2, .y = 3, .bar = true } }; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(std.testing.allocator); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 8; + try cursorDecor(&g, std.testing.allocator, &surface, fixedCellLayout(&g), 100, 100); + try std.testing.expectEqual(@as(usize, 1), g.decor.items.len); + const bar = g.decor.items[0]; + try std.testing.expectApproxEqAbs(@as(f32, -0.32), bar.y0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, -0.48), bar.y1, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), bar.br, 0.0001); + try std.testing.expectEqual(@as(f32, 1), bar.fr); } fn palColor(idx: u8) [3]u8 { @@ -6613,17 +6667,24 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); - var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; - surface.tag_layers[0] = layers[0]; - try std.testing.expect(gripCellChanged(&surface, 0, 0)); - try std.testing.expect(gripCellChanged(&surface, 1, 0)); - try std.testing.expect(!gripCellChanged(&surface, 2, 0)); - try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null); - layers[0].kind = .column; - try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null); + const surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs }; + // A grip two cells wide at the top left, in both frames. + const grip = [_]pardes.Region{.{ .kind = .grip, .serial = 1, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}; + var cover: [12]Cover = undefined; + var previous_cover: [12]Cover = undefined; + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + coverFrame(&previous_cover, 6, 2, &.{}, &.{}, &grip); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 0, 0)); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); + try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); + try std.testing.expect(!cover[2].grip); + // A column's grip has no glyph of a pane's. + coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); + try std.testing.expect(!cover[0].grip); + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + const chrome: pardes.Chrome = .{}; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); @@ -6647,7 +6708,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { - emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], cover[0..6], col, null, old, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0); try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0); } @@ -6802,10 +6863,12 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { g.vbuf_cells = capacity; } -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { +/// A pipeline over cell instances: the cells' own (ui.frag, the atlas, no +/// blending) or the decor's (decor.frag, blended like the overlay). +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, blend: c.SDL_GPUColorTargetBlendState) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + const fs = try makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ @@ -6822,8 +6885,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") }, .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") }, .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") }, + .{ .location = 7, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "clip_x0") }, }; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; @@ -7135,7 +7199,10 @@ fn latencyNote(comptime fmt: []const u8, args: anytype) void { _ = libc.write(latency_fd, line.ptr, line.len); } -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { +/// Writes the frame to latest.ppm in the capture directory, and a panel +/// transition's frame `transition` to transition-.ppm too, so a test +/// can hold one partway (test/gui_golden.py). +fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32, transition: ?u16) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -7168,10 +7235,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const pixels: [*]const u8 = @ptrCast(mapped); - try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); + try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); + var name: [24]u8 = undefined; + if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); } -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { +fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { // A latency trace times frames, and an 8 MB file write per frame would // stall the loop it is timing. if (g.capture_dir.len == 0 or latency_fd >= 0) return; @@ -7185,8 +7254,8 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; - const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/{s}.ppm.tmp", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/{s}.ppm", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.CaptureWriteFailed; @@ -7212,116 +7281,263 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -fn addCursorBar( - builder: *OverlayBuilder, - x: u16, - y: u16, - bar: bool, - visual_height: f32, - layout: CellLayout, - rgb: [3]u8, -) void { - if (!bar) return; - const previous_ink = builder.ink; - builder.ink = true; - defer builder.ink = previous_ink; - const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; - const height = std.math.clamp(visual_height, 1.0, layout.h); - const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - builder.addRect( - x0, - y0, - x0 + @max(1.0, layout.w / 8.0), - y0 + height, - .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }, - ); +/// One flat, hard-edged rectangle of decor, in window pixels: clipped to the +/// window and dropped when empty, as the triangle overlay drew it, and moved +/// by `track` as its pane's cells are. +fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, rgb: [3]u8, alpha: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, win_w); + const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, win_w); + const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, win_h); + const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, win_h); + if (x1 <= x0 or y1 <= y0) return; + // decor.frag.glsl reads the colour from the ground and the coverage + // from the ink's red. + var instance: CellInstance = .{ + .x0 = (x0 / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (y0 / win_h) * 2.0, + .x1 = (x1 / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (y1 / win_h) * 2.0, + .u0 = 0, + .v0 = 0, + .u1 = 0, + .v1 = 0, + .fr = alpha, + .fg = 0, + .fb = 0, + .br = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .bg = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .bb = @as(f32, @floatFromInt(rgb[2])) / 255.0, + }; + setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); + try g.decor.append(gpa, instance); +} + +/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it +/// read from the regions and the palette: the page's rules and the still +/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's +/// in its track's group, notice rules in tier 4, bar cursors in tier 5. +fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, groups: *Groups, layout: CellLayout, win_w: f32, win_h: f32, cursors_shown: bool) !void { + g.decor.clearRetainingCapacity(); + const chrome = &surface.chrome; + const regions = surface.regionList(); + for (groups.items[0..groups.len], 0..) |*group, index| { + group.decor_start = @intCast(g.decor.items.len); + defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; + if (index == 0) { + const top = for (regions) |region| { + if (region.kind == .workspace_tag) break region.rect.h; + } else 0; + // The workspace tag is ruled off from the tags right under it. + const tag_under = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, + else => {}, + } else false; + const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + if (top != 0 and top < surface.rows and tag_under and height != 0) { + const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); + try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); + } + // The column bar is ruled off from the panes under it. + for (regions) |bar| { + if (bar.kind != .column_grip) continue; + const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; + try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + break; + } + // A tag on the last row runs on through the pixels under the grid. + const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const tag_last = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag, .workspace_tag => if (region.rect.h != 0 and region.rect.y + region.rect.h == surface.rows) break true, + else => {}, + } else false; + if (grid_bottom < win_h and tag_last) try addDecor(g, gpa, 0, grid_bottom, win_w, win_h, chrome.tag_bg, 1, null, win_w, win_h); + } + if (index <= groups.tracks) { + const track = group.track; + // A closing pane is gone: its chrome is the last frame's. + const closing = if (track) |active| active.phase == .closing else false; + const placed = if (closing) surface.previous_regions else regions; + const grid = if (closing) surface.previous_cells else surface.cells; + try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); + // A single spine runs down each column, through every tag and body. + if (index == 0) for (regions) |column| { + if (column.kind != .column) continue; + const x = @as(f32, @floatFromInt(column.rect.x)) * layout.w; + const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; + const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + }; + try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + } + // A notice chip is tagline hung over the body's top rows, so it is + // ruled off from the text beneath it the way a tag is: the same + // colour and thickness along its bottom, running to the window's + // edge when it does, fading up and dissolving with its band. + if (index == groups.notices) for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice) continue; + const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; + // A band still falling into place is cut at its row's top; its + // rule comes with it, and shows only once it is below that cut. + const rule_y = top + (1 + layer.slide) * layout.h; + if (rule_y - 1 < top) continue; + const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; + try addDecor(g, gpa, x0, rule_y - 1, rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h); + }; + if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); + } +} + +/// Each pane's rule between its tag and body, and its scroll rail: a page +/// coloured gutter with a thin track and its thumb, running down through the +/// pixels past the last whole row. Only the panes of group `index`. +fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + const page = chrome.page orelse bg_default; + for (regions) |rail| { + if (rail.kind != .rail or rail.rect.h == 0 or groupOf(groups, rail.serial) != index) continue; + const tag = for (regions) |region| { + if (region.kind == .tag and region.owner == rail.owner) break region; + } else continue; + if (@as(u32, tag.rect.x) + tag.rect.w > cols or @as(u32, tag.rect.y) + tag.rect.h > rows or + @as(u32, rail.rect.y) + rail.rect.h > rows) continue; + const x0 = @as(f32, @floatFromInt(rail.rect.x)) * layout.w; + const x1 = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; + // Like the band above it, the rule of a pane at the right edge runs + // on through the pixels past the last whole cell. Under a tag on + // top, over one below its body. + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const tag_bottom = tag.rect.y > rail.rect.y; + const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h); + + const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; + const body_end = rail.rect.y + rail.rect.h; + const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h); + if (rail.thumb_h != 0) { + const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; + const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; + try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + } + } } -fn buildOverlay( - g: *Gui, - core: ?*const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - sw: u32, - sh: u32, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, - transient_on: bool, - debug_on: bool, -) u32 { - if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; - appendWorkspacePet(g, core, surface, &builder); - // The workspace tag grows a row per line, so the border follows its foot. - const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H; - if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| { - const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2); - const rgb = topbar_pane_border_rgb; - builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); +/// The two short bars on each row of a grip with no mode glyph there, +/// centered in its anchor. Only the panes of group `index`. +fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + if (g.tagline_height < 6 or g.tagline_width < 3) return; + for (regions) |grip| { + if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + const x = grip.rect.x; + for (0..grip.rect.h) |line| { + const y = grip.rect.y + @as(u16, @intCast(line)); + if (x >= cols or y >= rows) continue; + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); + } } - if (core) |p| if (p.columnBarHeight() > 0) { - const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h; - builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); +} + +test "decor is read from the regions, each pane's in its own group" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + const layout = fixedCellLayout(&g); + const ndc = struct { + fn x(px: f32) f32 { + return px / 800 * 2 - 1; + } }; - const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; - if (grid_bottom < win_h and bottomTaglinePresent(surface)) { - const rgb = tagline_rgb; - builder.addRect(0, grid_bottom, win_w, win_h, .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); - } - if (transient_on) { - if (core) |p| appendPaneChrome(p, surface, layout, &builder); - appendTagGrips(g, surface, layout, &builder); - if (surface.cursor) |cursor| { - if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { - const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; - const role = cellFontRole(&cell); - const visual_height: f32 = if (role == .tagline) - @floatFromInt(g.tagline_height) - else - layout.h; - const cursor_layout = if (role == .tagline) - taglineLayoutForCell(g, cursor.x) - else - layout; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); - } + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); + // A spine one pixel wide at each column's left edge, down from under the + // workspace tag; a rail two pixels wide at each pane's. + for (core.col_x[0..core.ncol]) |x| { + const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + const spine = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; + } else return error.TestExpectedSpine; + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), spine.y1); + const rail = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + } else return error.TestExpectedRail; + try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + } + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); + const still = groups.items[0].decor_count; + + // Pane 1 moving: its rule and rail leave tier 0 for its track's group, + // which moves them with it. + const serial = core.panes[1].?.serial; + const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; + groups = makeGroups(&.{moving}, true); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, groups.tracks == 0); + const tracked = groups.items[1]; + try std.testing.expect(tracked.decor_count >= 4); + try std.testing.expectEqual(still, groups.items[0].decor_count + tracked.decor_count); + for (g.decor.items[tracked.decor_start..][0..tracked.decor_count]) |item| + try std.testing.expectEqual(@as(u32, @intFromEnum(pardes.animation.Transition.slide)), item.effect); + // Cursors stand still while panes move. + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); +} + +/// The bar cursors: the grid's where no layer has the cell, and each layer's +/// at its own pitch. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + const chrome = &surface.chrome; + if (surface.cursor) |cursor| { + if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { + const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; + const role = cellFontRole(cell); + const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; + const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h); + const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w; + const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height)))); + try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); } } - if (transient_on) for (surface.bodyLayers()) |*layer| { + for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; + const cursor = layer.cursor orelse continue; + if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; - if (layer.cursor) |cursor| { - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; - const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - if (top >= bottom) continue; - const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; - const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; - const x = left + @as(f32, @floatFromInt(cursor.x)) * width; - const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; - builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role))); - } - }; - if (transient_on) for (surface.tagLayers()) |*layer| { + const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + if (top >= bottom) continue; + const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; + const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; + const x = left + @as(f32, @floatFromInt(cursor.x)) * width; + const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; + try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + } + for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; @@ -7331,10 +7547,25 @@ fn buildOverlay( const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; const y = layer.viewport.y + cursor.y; - const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); - }; - if (transient_on) appendTouchOverlay(g, &builder, debug_on); + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); + try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h); + } +} + +/// The triangle overlay: the workspace pet and the touch debug view, over +/// everything. +fn buildOverlay( + g: *Gui, + core: ?*const pardes.Pardes, + surface: *const pardes.Surface, + sw: u32, + sh: u32, + debug_on: bool, +) u32 { + if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; + var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) }; + appendWorkspacePet(g, core, surface, &builder); + appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } @@ -7409,33 +7640,11 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expectEqual(@as(usize, 0), builder.len); } -fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { - if (core) |p| return if (role == .tagline) switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => p.chromeTheme().tag_fg, - } else p.theme().fg orelse fg_default; - return switch (cell.style.fg) { +fn cursorColor(chrome: *const pardes.Chrome, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { + return if (role == .tagline) switch (cell.style.fg) { .rgb => |rgb| rgb, - .index => |index| palColor(index), - // Detached frames carry explicit page backgrounds but blank cells need - // not carry foreground ink. Keep their insertion caret visible too. - .default => switch (cell.style.bg) { - .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140) - .{ 32, 32, 32 } - else - fg_default, - else => fg_default, - }, - }; -} - -test "detached insertion caret contrasts with blank light and dark pages" { - var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } }; - try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body)); - cell.style.bg = .{ .rgb = .{ 24, 28, 32 } }; - try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body)); - cell.style.fg = .{ .rgb = .{ 122, 188, 160 } }; - try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body)); + else => chrome.tag_fg, + } else chrome.fg orelse fg_default; } fn overlayRgb(rgb: [3]u8) OverlayColor { @@ -7447,185 +7656,6 @@ fn overlayRgb(rgb: [3]u8) OverlayColor { }; } -fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool { - return !cell.default and switch (cell.style.bg) { - .rgb => |bg| std.mem.eql(u8, &bg, &rgb), - else => false, - }; -} - -// The canonical surface still owns scrollbar positions and hit targets. Only -// their pixel ink is inset here; no layout, selection or mouse coordinates move. -fn appendPaneChrome( - core: *const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - builder: *OverlayBuilder, -) void { - const chrome = core.chromeTheme(); - const page = overlayRgb(core.theme().bg orelse bg_default); - for (core.panes, core.rects) |slot, r| { - const pane = slot orelse continue; - if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue; - if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; - const tag_y = core.tagTop(pane, r); - const body_y = core.bodyTop(pane, r); - const body_end = body_y + (r.h -| pane.tag_rows); - const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; - const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; - const tag_visible = for (tag_cells) |*cell| { - if (cellFontRole(cell) != .tagline) break false; - } else true; - if (tag_visible) { - const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h; - // Like the band above it, the rule of a pane at the right edge - // runs on through the pixels past the last whole cell. - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); - } - - // Do not paint over resize previews or debug text occupying the rail. - var thumb_start: ?u16 = null; - var thumb_end: u16 = body_y; - var row = body_y; - while (row < body_end) : (row += 1) { - const cell = &surface.cells[@as(usize, row) * surface.cols + r.x]; - if (cell.printableAscii() != ' ') break; - if (cellBackgroundIs(cell, chrome.scroll_thumb)) { - if (thumb_start == null) thumb_start = row; - thumb_end = row + 1; - } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break; - } - if (row != body_end) continue; - const y0 = @as(f32, @floatFromInt(body_y)) * layout.h; - const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; - const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1; - builder.addRect(x0, y0, x0 + layout.w, y1, page); - appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track)); - if (thumb_start) |first| { - const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h; - const thumb_y1 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h; - appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); - } - } - // A notice chip is tagline hung over the body's top rows, so it is ruled - // off from the text beneath it the way a tagline is: the same colour and - // thickness along its bottom, running to the window edge when it does. - for (surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != .notice) continue; - const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; - // A band still falling into place is cut at its row's top; its rule - // comes with it, and shows only once it is below that cut. - const rule_y = top + (1 + layer.slide) * layout.h; - if (rule_y - 1 < top) continue; - // ...and fades up and dissolves with it rather than standing alone. - var rule = overlayRgb(chrome.border); - rule.a = layer.fade; - builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule); - } - appendColumnSpines(core, surface.rows, layout, builder); -} - -// Tag handles use the canonical physical cells, including detached frames. -// Their tag text begins at the layer viewport and may use a narrower font. -fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { - if (g.tagline_height < 6 or g.tagline_width < 3) return; - for (surface.tagLayers()) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - // A mark on each of the tag's rows whose grip cell is blank: every - // row but the one with the mode's glyph. - for (0..layer.rows) |index| { - const y = layer.viewport.y + @as(u16, @intCast(index)); - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); - } - } -} - -test "column grips stay unmarked while pane marks retain physical centering" { - var g: Gui = undefined; - g.tagline_height = 12; - g.tagline_width = 6; - g.cell_h = 20; - var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); - var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - var vertices: [24]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; - appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - // Centered in the anchor, whose left edge is inset by the band's offset - // (8 px on this last row): the mark moves right by half of it. - try std.testing.expectApproxEqAbs(@as(f32, 61.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001); -} - -fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void { - const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2); - builder.addRect(x, y0, x + width, y1, color); -} - -fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { - const y0 = @as(f32, @floatFromInt(core.topBarHeight())) * layout.h; - const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; - // Down through the pixels past the last whole row, as tags run right. - const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; - if (y1 <= y0) return; - // A single boundary spans all panes, including their tags. Its left edge - // agrees with the square scroll markers and existing move-box hit area. - for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| { - if (width == 0) continue; - const x = @as(f32, @floatFromInt(col)) * layout.w; - builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border)); - } -} - -test "square scroll markers share the column edge and have no rounded end inset" { - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 })); - appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 })); - try std.testing.expectEqual(@as(usize, 12), builder.len); - try std.testing.expectEqual(vertices[0].x, vertices[6].x); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001); -} - -test "column spines remain continuous across every pane tag and body" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - for (0..2) |col| { - const first = vertices[col * 6]; - const bottom = vertices[col * 6 + 2]; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001); - try std.testing.expectEqual(@as(f32, -1), bottom.y); - try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001); - } -} - fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; diff --git a/src/pardes.zig b/src/pardes.zig index f8087bdb..616747fa 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ 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 Chrome = @import("surface.zig").Chrome; pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; diff --git a/src/surface.zig b/src/surface.zig index db7dd468..07b593b1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -344,6 +344,13 @@ pub const Region = struct { column_grip, column_tag, workspace_tag, + /// A column below the workspace tag, its bar and panes: its left + /// edge is the spine a pixel shell rules down it. + column, + /// A drag's guide or a seam's hover hint, drawn over the panes. + guide, + /// The debug stats box, drawn over the panes. + debug, }; pub const none = std.math.maxInt(u8); @@ -368,7 +375,30 @@ pub const Region = struct { clip: u16 = 0, }; -pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; +/// Guides a frame can draw: a run of a column-move rail per pane it skips +/// the tags of, and a handful of border and hover dashes. +pub const MAX_GUIDES = MAX_PANES + 4; +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1; + +/// The colours a pixel shell draws its chrome in (rules, rails, grips, the +/// tag underlay), resolved by the core each frame, so a frontend attached +/// to a detached session draws them from the frame alone. +pub const Chrome = struct { + /// The theme's ground and ink; null where it leaves them to the shell. + page: ?[3]u8 = null, + fg: ?[3]u8 = null, + /// Rules and column spines. + border: [3]u8 = .{ 0, 0, 0 }, + /// The rule under the workspace tag. + topbar_rule: [3]u8 = .{ 0, 0, 0 }, + tag_bg: [3]u8 = .{ 0, 0, 0 }, + /// The focused pane's and column's tag underlay: `tag_bg` unless + /// FocusTint is on. + tag_focus_bg: [3]u8 = .{ 0, 0, 0 }, + tag_fg: [3]u8 = .{ 0, 0, 0 }, + scroll_track: [3]u8 = .{ 0, 0, 0 }, + scroll_thumb: [3]u8 = .{ 0, 0, 0 }, +}; /// 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. @@ -385,6 +415,7 @@ pub const Surface = struct { rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, nregions: u16 = 0, + chrome: Chrome = .{}, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -395,6 +426,7 @@ pub const Surface = struct { previous_cells: []const Cell = &.{}, previous_body_layers: []const Layer = &.{}, previous_tag_layers: []const Layer = &.{}, + previous_regions: []const Region = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const Layer { @@ -409,6 +441,12 @@ pub const Surface = struct { return s.regions[0..s.nregions]; } + pub fn addRegion(s: *Surface, r: Region) void { + std.debug.assert(s.nregions < s.regions.len); + s.regions[s.nregions] = r; + s.nregions += 1; + } + /// 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; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index e52155a3..27c39150 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -10,8 +10,9 @@ 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea -14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 -15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 -16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 -17-terminal 304a84d1ef871f581d0a3fade9bc1acdcc87a3612c607548ab0d2851871753ee +13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c +14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a +15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a +17-terminal a1305fd755b6bc957228bbf28833f972c2355af9abf40b77f76b30741109574f +18-mid-transition d4f353ef5fda80411cd9de9729e5b23f59ff7511c10774e69214152ee8070725 diff --git a/test/gui_golden.py b/test/gui_golden.py index 327911cc..d97009c9 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -106,7 +106,9 @@ def run(args): display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) if not display.is_absolute(): display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display - inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', + # The virtual clock: a transition's frames are the same frames on any + # machine, so one partway through can be hashed too. + inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), @@ -117,11 +119,12 @@ def run(args): first = require(wait_for(lambda: next((s for s, tag in panes(client).items() if 'shapes.zig' in tag), None)), 'source pane missing') - def scene(label): - results.append((label, settled(root))) + def scene(label, frame='latest.ppm'): + settled(root) + results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm')) + shutil.copyfile(root / frame, args.keep / (label + '.ppm')) print(label, results[-1][1][:16], flush=True) def mouse(kind, button, x, y): @@ -196,6 +199,13 @@ def run(args): client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') scene('17-terminal') + # Partway through a transition: the panes, the picture among them, + # sliding over for a new column, frame 6 of PanelSlide's 12. Its + # chrome slides with each pane. + client.write('/ctl', b'PanelSlide\n') + client.write(f'/pane/{first}/exec', b'Newcol\n') + require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') + scene('18-mid-transition', 'transition-6.ppm') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: GOLDENS.write_text(lines) -- cgit v1.3 From cd6ce6c2b3015c776644fb3866280f5dfd03addc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:43:46 -0300 Subject: A notice's word under the pointer is lit, in its band and the grid; mark_hover is gone Open point 6 of docs/render-pipeline.md: notice words are Look and Exec targets, so the hover affordance headers have is back on notices, painted once in the notice's layer and joined into the grid from it. Deleted: Surface.mark_hover, Cell.hover and render's per-frame reset of it, none of which had been set since stage 5 (macOS loses its glass hover rect; its build is ignored for now). Snapshot goldens unchanged. Shared files touched: macos.zig (the hover bit's removal). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- docs/render-pipeline.md | 8 +++++--- src/body_layer.zig | 4 +--- src/draw.zig | 47 +++++++++++++++++++++++++++++++++++++++-------- src/macos.zig | 18 +++++------------- src/surface.zig | 16 +--------------- 5 files changed, 51 insertions(+), 42 deletions(-) (limited to 'src/surface.zig') diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 6c5e4866..7299b357 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -845,9 +845,11 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. Carried forward from stage 8 (a GUI-only stage) to the hover - change after it, which also retires `mark_hover`: the affordance is a - core paint every shell and snapshot sees. + layer then. Done right after stage 8: a notice's word under the pointer + is lit as a header's is, in the layer and so in the grid's copy joined + from it. The same change deleted `mark_hover`, the `Cell.hover` bit and + the per-frame reset of it (never set since stage 5; macOS loses its + glass hover rect, accepted with open point 4). 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The diff --git a/src/body_layer.zig b/src/body_layer.zig index 583a2e98..e9407719 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -294,9 +294,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below - // win over this quiet affordance. mark_hover carries the - // cells this paints out to the hosts as the affordance rect — - // macOS composes its glass overlay over exactly these. + // win over this quiet affordance. const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) diff --git a/src/draw.zig b/src/draw.zig index 4242a6e7..3798bf59 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -163,10 +163,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // 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; - // Stale hover bits must not survive into a frame whose pointer has - // moved on: the ordinary content paint does not clear fields it - // never set. - for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; @@ -661,10 +657,11 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, 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 }); _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - // The word under the pointer: on a header even while it is edited, - // but not on a notice (a message is not a command line), nor on a - // column a carried column's pointer is aiming at a place over. - const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); + // 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| @@ -1033,6 +1030,40 @@ test "place puts every part where the painters and layers put it, in every layou } } +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 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(); diff --git a/src/macos.zig b/src/macos.zig index f7e03cd4..4d8f2883 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1866,13 +1866,10 @@ fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cel if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); - for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { - // The tag text is the layer's to draw, but a look-hover on a tag - // word is a canonical fact: carry the hover bit through so the - // glass overlay still finds the affordance rect. - cell.* = encodeCell(.{}); - cell.flags |= @as(u8, @intFromBool(was.hover)) * cell_flag_hover; - } + // The tag text is the layer's to draw. (The look-hover's glass + // rect went with the core's hover bit, stage 8's follow-up.) + _ = source; + for (cells[start..][0..count]) |*cell| cell.* = encodeCell(.{}); } } @@ -2574,7 +2571,7 @@ fn encodeCell(cell: pardes.Cell) Cell { .fg = encodeColor(cell.style.fg), .bg = encodeColor(cell.style.bg), .attrs = encodeAttrs(cell.style), - .flags = encodeCellFlags(cell.default, cell.style.font_role, cell.hover), + .flags = encodeCellFlags(cell.default, cell.style.font_role, false), .len = if (cell.default) 1 else cell.len, }; if (cell.default) @@ -3131,16 +3128,11 @@ test "mac tag layer ABI preserves logical capacity and physical grip" { try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8)); try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]); var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false })); - // A look-hover under the layer is a canonical fact and survives the blank; - // the glyph it marks does not, because the layer redraws that text itself. var source: [24]pardes.Cell = @splat(.{}); - source[11].hover = true; blankTagCells(&canonical, 8, 3, &source, &core.surface.tag_layers); try std.testing.expectEqual(@as(u8, 'G'), canonical[9].text[0]); try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]); try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len); - try std.testing.expectEqual(@as(u8, 0), canonical[10].flags & cell_flag_hover); - try std.testing.expectEqual(cell_flag_hover, canonical[11].flags & cell_flag_hover); } test "mac window blur builtin reaches the ABI without changing opacity" { diff --git a/src/surface.zig b/src/surface.zig index 07b593b1..b6f41853 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -38,12 +38,6 @@ pub const Cell = struct { len: u8 = 1, style: CellStyle = .{}, default: bool = true, - /// The look-hover affordance is on this cell: the word under the pointer - /// is a real Look or Exec operand, and hosts that draw the quiet tint as - /// more than a flat fill — macOS renders it as a glass overlay — read - /// this instead of guessing from the background colour, which a real - /// selection shares. - hover: bool = false, pub fn grapheme(c: *const Cell) []const u8 { return c.text[0..c.len]; @@ -405,10 +399,6 @@ pub const Chrome = struct { pub const Surface = struct { pub const PointerShape = enum(u8) { arrow, link, target }; pointer_shape: PointerShape = .arrow, - /// Set only for the duration of the look-hover paint, so the cells that - /// paint the quiet affordance can carry the hover bit through the ABI - /// without every selection painter learning a new parameter. - mark_hover: bool = false, body_layers: [MAX_PANES]Layer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, @@ -474,11 +464,7 @@ pub const Surface = struct { pub fn at(s: *Surface, x: u16, y: u16) *Cell { std.debug.assert(x < s.cols and y < s.rows); - const cell = &s.cells[@as(usize, y) * s.cols + x]; - // The look-hover paint runs with mark_hover set, and its cells are - // exactly the ones this marks — see render's preview block. - if (s.mark_hover) cell.hover = true; - return cell; + return &s.cells[@as(usize, y) * s.cols + x]; } pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void { -- cgit v1.3