diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 14:37:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | d88e381793c6505bf3f2af43705bd2fa32f55ef6 (patch) | |
| tree | d4f3966c4eacf7fee43b6f18fc83f136676f835d /src/draw.zig | |
| parent | 7284b72396398213356577345151bd79e497d27c (diff) | |
| download | pardes-d88e381793c6505bf3f2af43705bd2fa32f55ef6.tar.gz pardes-d88e381793c6505bf3f2af43705bd2fa32f55ef6.zip | |
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.
Diffstat (limited to 'src/draw.zig')
| -rw-r--r-- | src/draw.zig | 187 |
1 files changed, 96 insertions, 91 deletions
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); |
