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/draw.zig | 913 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 src/draw.zig (limited to 'src/draw.zig') diff --git a/src/draw.zig b/src/draw.zig new file mode 100644 index 00000000..5db6da30 --- /dev/null +++ b/src/draw.zig @@ -0,0 +1,913 @@ +//! The frame: how the core builds the canonical Surface every shell draws. +//! `render` is the whole order, top to bottom: the page, each pane (its +//! grip, tag and body, and the body layer of its compact rows), the notice +//! 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 pardes = @import("pardes.zig"); +const layout = @import("layout.zig"); +const animation = @import("animation.zig"); +const config = @import("config.zig"); +const panes = @import("panes.zig"); +const modal = @import("modal.zig"); +const tracy = @import("tracy.zig"); +const colors = @import("colors.zig"); +const edit = @import("edit.zig"); +const mouse = @import("mouse.zig"); +const tagline = @import("tagline.zig"); +const body_layer = @import("body_layer.zig"); +const Messages = @import("Messages.zig"); +const Color = @import("surface.zig").Color; +const CellStyle = @import("surface.zig").CellStyle; +const Cell = @import("surface.zig").Cell; +const Surface = @import("surface.zig").Surface; +const Pardes = pardes.Pardes; +const Pane = panes.Pane; +const Text = panes.Text; +const Rect = layout.Rect; +const TagKind = @import("tag_layer.zig").Kind; +const pdf_enabled = panes.Pdf.enabled; +const wordBoundsAtCol = pardes.wordBoundsAtCol; +const BOX_H = pardes.BOX_H; +const TAG_GAP = pardes.TAG_GAP; +const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; +const TOPBAR_H = pardes.TOPBAR_H; +const COLBAR_H = pardes.COLBAR_H; +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; + +pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { + panes.File.refreshHighlights(p); + const s = &p.surface; + const ncells = @as(usize, p.screen_w) * p.screen_h; + if (s.cells.len != ncells) { + const cells = try p.gpa.alloc(Cell, ncells); + p.gpa.free(s.cells); + s.cells = cells; + } + s.cols = p.screen_w; + s.rows = p.screen_h; + s.cursor = null; + s.pointer_shape = .arrow; + if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { + if (preview.probe.link != null and preview.pane < p.panes.len) { + if (p.panes[preview.pane]) |pane| { + if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) + s.pointer_shape = .link; + } + } + }; + // A look-hover preview means the pointed word is a real Look or Exec + // operand: the same plumbing a click would act on. Hosts that model + // 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.cols = 0; + s.nimages = 0; + s.npanel_tracks = 0; + s.previous_cells = &.{}; + s.previous_body_layers = &.{}; + s.previous_tag_layers = &.{}; + s.cell_diffs = &.{}; + const chrome = p.chromeTheme(); + const th = p.theme(); + // Separators have their own role; a scrollbar need not frame the page. + s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); + + for (&p.panes, 0..) |*slot, id| { + const pane = slot.* orelse continue; + pane.body_rows = 0; + 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, 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; + 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, 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); + var leader_buf: [16]u8 = undefined; + for (pane.notices.slice(), 0..) |kind, i| { + const row = first + @as(u16, pane.notices.row[i]); + // 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; + // 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 }; + } + } + + try paintHeaders(p); + try renderTagLayers(p, arena); + + switch (p.drag) { + .border_v => |d| { + if (d.cur_x < s.cols) { + var row: u16 = p.topBarHeight(); + while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); + } + 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 end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + }; + }, + .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, "╌"); + }, + .column_move => |d| rail: { + const source = layout.columnBySerial(p, d.serial) orelse break :rail; + // Where the column's left edge would land, reordered or not. + 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, + }; + // 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 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. + // At the window's left edge there is no seam, so it runs just + // past that scroll rail instead. It takes the lit grip's + // 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(); + 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; + s.overlayDash(x, row, "╎"); + s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; + } + }, + .move => |d| if (d.cur_x < s.cols) { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { + var col: u16 = p.col_x[placement.preview_col]; + while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { + s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); + } + s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); + } + }, + .none, .select => if (p.pointer_inside) { + // hover hints on a pane's own trailing edge + var c: usize = 0; + while (c + 1 < p.ncol) : (c += 1) { + 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, "╎"); + } + } + for (0..p.ncol) |cc| { + if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; + // the same seam the h-handle hit test picks (see there) + 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, "╌"); + } + } + for (0..p.ncol -| 1) |cn| { + if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; + if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue; + 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, "╌"); + } + }, + } + + // debug overlay: a small stats box drawn last, top-right ("Debug") + if (p.settings.debug) if (p.panes[p.active]) |at| { + var nlive: usize = 0; + for (p.panes) |slot| { + if (slot != null) nlive += 1; + } + var sb_off: usize = undefined; + var sb_total: usize = undefined; + if (at.file) |*f| { + sb_off = f.scroll; + sb_total = panes.File.nlines(p.gpa, f); + } else if (at.pdfPage()) |page| { + sb_off = page; + sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; + } else { + const sb = panes.Terminal.scrollbar(at); + sb_off = sb.offset; + sb_total = sb.total; + } + const effective_font = p.settings.font.effective_name.get(); + const font_name = if (effective_font.len == 0) "default" else effective_font; + var ov_buf: [256]u8 = undefined; + const text = std.fmt.bufPrint( + &ov_buf, + "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", + .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, + ) catch ""; + // wide enough for the longest theme name compiled in, which is the + // only row here whose width is not ours to choose + const ow: u16 = 38; + 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) { + // vaxis single_rounded border ring, default style + s.set(ox, p.topBarHeight(), "╭", .{}); + s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); + s.set(ox, p.topBarHeight() + oh - 1, "╰", .{}); + s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{}); + var bx = ox + 1; + while (bx < ox + ow - 1) : (bx += 1) { + s.set(bx, p.topBarHeight(), "─", .{}); + s.set(bx, p.topBarHeight() + oh - 1, "─", .{}); + } + var by = p.topBarHeight() + 1; + while (by < p.topBarHeight() + oh - 1) : (by += 1) { + s.set(ox, by, "│", .{}); + s.set(ox + ow - 1, by, "│", .{}); + } + s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); + var lines = std.mem.splitScalar(u8, text, '\n'); + var ly: u16 = p.topBarHeight() + 1; + while (lines.next()) |line| : (ly += 1) { + if (ly >= p.topBarHeight() + oh - 1) break; + _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); + } + } + }; + try p.presentation.submit(p, s); + return composeAsciiTransitions(p, arena, s); +} + +pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { + _ = p; + if (!canonical.hasPanelDiff()) return canonical; + var presented: ?*Surface = null; + for (canonical.panelTracks()) |track| { + if (!track.effect.composedByCore() or track.phase == .closing) continue; + const area = animation.CellArea.of(track.to); + const col_end = @min(canonical.cols, area.x0 +| area.cols); + const row_end = @min(canonical.rows, area.y0 +| area.rows); + var row: u16 = area.y0; + while (row < row_end) : (row += 1) { + var col: u16 = area.x0; + while (col < col_end) : (col += 1) { + const index = @as(usize, row) * canonical.cols + col; + const composed = composedCell(canonical, track, area, col, row, index) orelse + continue; + if (composed.visuallyEqual(&canonical.cells[index])) continue; + if (presented == null) { + const next = try arena.create(Surface); + next.* = canonical.*; + next.cells = try arena.dupe(Cell, canonical.cells); + presented = next; + } + presented.?.cells[index] = composed; + } + } + } + return presented orelse canonical; +} + +fn composedCell( + canonical: *const Surface, + track: animation.Track, + area: animation.CellArea, + col: u16, + row: u16, + index: usize, +) ?Cell { + if (track.effect == .ascii) { + const diff = switch (canonical.cell_diffs[index]) { + .ascii => |diff| diff, + .unchanged, .visual => return null, + }; + const byte = diff.byteAt(track.frame); + if (byte == diff.to) return null; + if (track.frame == 0) return canonical.previous_cells[index]; + return withByte(canonical.cells[index], byte); + } + return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) { + .old => canonical.previous_cells[index], + .byte => |byte| withByte(canonical.cells[index], byte), + // Churn belongs on the glyph, not on the pane's empty space, and it + // borrows whichever side of the diff actually has one. + .churn => |byte| blk: { + const new_cell = &canonical.cells[index]; + if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); + const old_cell = &canonical.previous_cells[index]; + if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); + break :blk old_cell.*; + }, + .at => |offset| blk: { + const source_col = @as(i32, col) + offset.cols; + const source_row = @as(i32, row) + offset.rows; + // A glyph whose source lies outside the pane has not arrived + // yet: the frozen old cell is the only truthful thing under it. + if (source_col < area.x0 or source_row < area.y0 or + source_col >= @as(i32, area.x0) + area.cols or + source_row >= @as(i32, area.y0) + area.rows or + source_col >= canonical.cols or source_row >= canonical.rows) + break :blk canonical.previous_cells[index]; + const source = @as(usize, @intCast(source_row)) * canonical.cols + + @as(usize, @intCast(source_col)); + break :blk canonical.cells[source]; + }, + }; +} + +fn withByte(cell: Cell, byte: u8) Cell { + var out = cell; + out.text[0] = byte; + out.len = 1; + out.default = false; + return out; +} + +/// Whether a cell shows a glyph a character effect can work on. An unpainted +/// cell and a space are both blank space, whatever style they carry. +fn hasGlyph(cell: *const Cell) bool { + return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); +} + +fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); + if (r.w <= config.GUTTER or r.h == 0) return; + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter + const tw = r.w - config.GUTTER; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; + + // text area resets to terminal-default cells (vaxis clear semantics); + // light themes paint the page over it. + { + // Two full passes over every cell in the pane, every frame. + const tz_clear = tracy.zone(@src(), "paneClear"); + defer tz_clear.end(); + s.clearRect(tx, r.y, tw, r.h); + if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); + } + + const box_bg = if (active) chrome.box else chrome.box_dim; + const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; + const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; + const box_style: CellStyle = .{ + .fg = .{ .rgb = box_ink }, + .bg = .{ .rgb = box_bg }, + // Grips keep physical grid dimensions, independently of tag text. + .font_role = .tagline, + }; + s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); + s.set(r.x, tag_y, switch (pane.body.mode) { + .normal => config.tag_normal, + .insert => config.tag_insert, + .tty => config.tag_tty, + }, box_style); + + try paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + + // Folded panes retain their body state, but neither body pixels nor + // its cursor belong outside the one-row tag rectangle. + if (pane.collapsed) return; + + if (comptime pdf_enabled) + if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; + + if (pane.image) |*iv| { + // Below the notice chips: a picture is drawn after the cells (the + // GUI's image pass, kitty's z-order), so a chip over it would be + // painted out. Text gets the overlay; a picture gives up the rows. + const shown = pane.notices.span(); + const image_h = (r.h -| pane.tag_rows) -| shown; + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); + // 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 }); + return; + } + + try body_layer.renderBody(p, arena, pane, r, id, active, true); +} + +/// 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) !void { + const s = &p.surface; + 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 }; + } + } + } + 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, + }); + } + // 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 }; + } + } + } + }; + 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 }; + } + } +} + +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"); + while (words.next()) |word| { + if (!std.mem.eql(u8, word, "Tty")) continue; + const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); + return .{ .lo = lo, .hi = lo + word.len }; + } + return null; + } + var start: usize = 0; + const name = if (pane.file) |file| + if (pane.prompt == .name) pane.input.own orelse file.path else file.path + else if (pane.image) |state| blk: { + // Image tags put renderer controls before the path. + start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null; + break :blk state.path; + } else if (comptime pdf_enabled) blk: { + break :blk if (pane.pdf) |state| state.path else return null; + } else return null; + const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; + if (basename == name.len) return null; + 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 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 }; + std.mem.swap(Surface, &p.surface, &temporary); + paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { + std.mem.swap(Surface, &p.surface, &temporary); + return err; + }; + std.mem.swap(Surface, &p.surface, &temporary); + // 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 = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .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.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; + const first = p.bodyTop(pane, r); + 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; + // 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 = cx, + .y = first + @as(u16, pane.notices.row[i]), + .w = chip, + .h = BOX_H, + }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + 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; + 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); + } + }; +} + +/// 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 { + _ = arena; + const layer = &p.surface.tag_layers[index]; + if (viewport.w == 0) { + layer.cols = 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); + 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.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, + }) 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 }); + 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) { + 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 }; + } + }; + } +} + +pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + @min(TAG_TEXT_INSET, r.w); + const tw = r.w -| TAG_TEXT_INSET; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const rows = pane.tag_rows; + const page_rgb = th.bg orelse th.tag_bg; + // Focus is visible along the tag without changing its live text grid. + const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; + // Padding belongs to the same font row but keeps its default ink: a + // role must not manufacture a foreground color for blank cells. + s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); + s.fill(tx, tag_y, tw, rows, .{ + .bg = .{ .rgb = tag_bg }, + .font_role = .tagline, + }); + const whole = try tagline.tagText(p, arena, pane); + const tail = tagline.curTail(pane); + const prefix_len = whole.len - tail.len; + // A row per line shown, from `tag_top`: the tag's first line carries + // the prefix, the rest are the tag's own text alone. + const top = pane.tag_top; + var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); + var split = std.mem.splitScalar(u8, whole, '\n'); + for (0..top) |_| _ = split.next(); + for (lines[0..rows]) |*line| line.* = split.next() orelse ""; + // The keyboard's caret, as a row and a byte offset in that row's + // line: in the tag's own text when the tag has the keyboard, in the + // path while a name is drafted. + const Caret = struct { row: usize, at: usize }; + const caret: ?Caret = if (!active) + null + else if (pane.prompt == .name) + .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } + else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { + const row: usize = @intCast(pane.tag.cur_row - top); + break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + } else null; + if (caret) |at| { + const line = lines[at.row]; + const col = panes.File.rawDisplayCol(line, at.at); + var scroll: usize = pane.tag_scroll; + if (col < scroll) scroll = col; + if (col >= scroll + capacity) scroll = col - capacity + 1; + var raw = panes.File.rawAtDisplay(line, scroll); + scroll = panes.File.rawDisplayCol(line, raw); + if (col >= scroll + capacity) { + raw = modal.nextGrapheme(line, raw); + scroll = panes.File.rawDisplayCol(line, raw); + } + pane.tag_scroll = @intCast(scroll); + } else if (pane.focus != .tag) pane.tag_scroll = 0; + for (lines[0..rows], 0..) |line, row| { + const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; + if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + const hi = @min(name.hi, tag.len); + if (lo < hi) { + var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + while (col < end and col < tw) : (col += 1) + s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; + } + }; + 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, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + }; + } + // The tag's own selections, while it has the keyboard, a line at a + // time. A range of one grapheme is the block cursor, not a selection. + if (pane.focus == .tag) { + var ranges: [Text.max_selections]modal.Selection = undefined; + const got = pane.tag.ranges(whole, 0, &ranges); + for (ranges[0..got.n]) |range| { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (modal.nextGrapheme(whole, lo) >= hi) continue; + const first = modal.positionAt(whole, lo); + const last = modal.positionAt(whole, hi); + var row = @max(first.row, top); + while (row <= last.row and row < top + rows) : (row += 1) { + const line = lines[row - top]; + const from = if (row == first.row) first.col else 0; + const to = @min(line.len, if (row == last.row) last.col else line.len); + var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(line, to); + const y = tag_y + @as(u16, @intCast(row - top)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + } + // the caret: bar while typing, block in normal mode (same rule as a body) + if (caret) |at| { + const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; + const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; + } + + 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, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + } +} -- cgit v1.3