//! 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 vaxis = @import("vaxis"); 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 builtins = @import("builtins.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; 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("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; /// 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; 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); 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); s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .dirty = tagline.dirty(pane), .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; const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .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)); } 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. 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; 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(); 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]); 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) 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. // Lift: the focused pane rises, the others settle back, each on its // spring; a notice floats while Lift is on. const lift = liftStyle(p); for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| { const pane = slot orelse continue; if (serial.* != pane.serial) { serial.* = pane.serial; spring.* = .{}; } // How focused it is, which both a lift and a dim follow: at this // frame's own time, not the stepped animations' 16 ms grid. A grid // snaps (it has no in-between), and so does nothing that shows it. const shown = lift != .off or inactiveDim(p) != 0; const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off); // A flavour exaggerates what is only a few pixels (Motion.gain). spring.retarget(if (id == p.active) animation.Motion.of(p.settings.motion).gain else 0, p.now_ns, motion); _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { .grip, .tag, .body, .rail => region.lift = if (lift != .off) std.math.clamp(p.lifts[region.owner].value(p.now_ns), -1, 2) else 0, .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; 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 => {}, }; } } /// A pane's grip in the grid: `box` focused, else halfway between its ring /// and `box_dim`, so it shows on its tag without the ring a pixel shell /// draws (gui.zig draws its button from the region and Chrome instead). fn gripBg(p: *const Pardes, active: bool) [3]u8 { const chrome = p.chromeTheme(); if (active) return chrome.box; return colors.mix(boxBorder(p), chrome.box_dim); } /// An unfocused grip's ring, and a dirty one's fill (colors.Theme). pub fn boxBorder(p: *const Pardes) [3]u8 { return p.theme().box_border orelse p.chromeTheme().box_dim; } pub fn boxDirty(p: *const Pardes) [3]u8 { const th = p.theme(); return th.box_dirty orelse th.diagnostic_warning orelse th.num; } fn inkOn(bg: [3]u8) [3]u8 { const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; } /// The focused cursor's box in grid cells, at the pitch it is drawn at: a /// tag's at the tagline's (Layer.Metrics), else a grid cell. fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box { const cursor = s.cursor orelse return null; const m = p.row_metrics; const bw: f32 = @floatFromInt(@max(1, m.body_w)); const bh: f32 = @floatFromInt(@max(1, m.body_h)); const tw: f32 = @floatFromInt(@max(1, m.tagline_w)); const th: f32 = @floatFromInt(@max(1, @min(m.tagline_h, m.body_h))); for (s.tagLayers()) |*layer| { const caret = layer.cursor orelse continue; if (layer.rows == 0 or layer.viewport.y + caret.y != cursor.y) continue; if (cursor.x < layer.viewport.x or cursor.x >= layer.viewport.x + layer.viewport.w) continue; const w = if (caret.bar) tw / bw / 8 else tw / bw; // A caret past a full row's end sits at the tag's right edge, where // the grid's is clamped too. const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)); const x = @min(@as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw, right - w); return .{ .x = x, .y = @as(f32, @floatFromInt(cursor.y)) + (bh - th) / 2 / bh, .w = w, .h = th / bh }; } return .{ .x = @floatFromInt(cursor.x), .y = @floatFromInt(cursor.y), .w = if (cursor.bar) 1.0 / 8.0 else 1, .h = 1 }; } /// G3 (docs/render-pipeline.md §5.4): the focused cursor on its springs, /// at the Motion flavour's pace, a jump gliding and a step or a keystroke in /// insert mode landing at once; blinking unless typing. A pixel shell's: a /// grid's cursor is the terminal's own. /// /// The springs hold the cursor where the pane's final layout puts it, and /// the quad is drawn through the pane's frame (paneFrame): while the pane /// slides, zooms or is dragged, the cursor rides with its text. A pane that /// moved in the layout carries the springs with it (they keep their place /// in the pane), so with no transition the cursor lands with the text. A /// focus change glides from where the cursor is drawn to the new pane's /// cursor, wherever that pane is drawn now. fn glideCursor(p: *Pardes, s: *Surface) void { s.chrome.cursor_glide = 0; s.chrome.cursor_alpha = 1; s.chrome.cursor_idle_ms = std.math.maxInt(u32); if (!builtins.capabilities.lift) return; const box = focusedCursorBox(p, s) orelse return; const motion = animation.Motion.of(p.settings.motion); var snap = if (s.cursor) |cursor| cursor.bar else false; var frame: PaneFrame = .{}; // In a column's or the workspace's tag the cursor is not the pane's, // and does not move with it. var inside = false; if (p.panes[p.active]) |pane| { const rect = p.rects[p.active]; const cx = box.x + box.w / 2; const cy = box.y + box.h / 2; inside = cx >= @as(f32, @floatFromInt(rect.x)) and cx <= @as(f32, @floatFromInt(rect.x + rect.w)) and cy >= @as(f32, @floatFromInt(rect.y)) and cy <= @as(f32, @floatFromInt(rect.y + rect.h)); if (inside) frame = paneFrame(s, pane.serial, rect); const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; const view = p.cursor_view; const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; if (view.serial == pane.serial and view.inside == inside) { // The pane moved in the layout: the springs move with it (its // own cursor's; one in a column's tag stays). if (inside) p.cursor_glide.shift(origin[0] - view.origin[0], origin[1] - view.origin[1]); // Typing never trails: insert mode lands every move. if (pane.body.mode == .insert) snap = true; // Scrolled (a file's view, a terminal's output): the quad moves // with the text and lands, never gliding against text that // jumped (every Enter at a prompt scrolls one and moves the // other). if (inside and line != view.line) { p.cursor_glide.shift(0, @floatFromInt(view.line - line)); snap = true; } } else if (view.serial != 0 and p.cursor_glide.placed) { // Focus moved (to another pane, or between a pane and a // column's or the workspace's tag): the glide starts where the // cursor is drawn, in the old place's frame, and heads for the // new cursor through the new one's. const old: PaneFrame = if (!view.inside) .{} else for (p.panes, 0..) |slot, id| { const other = slot orelse continue; if (other.serial != view.serial) continue; const r = p.rects[id]; p.cursor_glide.shift(@as(f32, @floatFromInt(r.x)) - view.origin[0], @as(f32, @floatFromInt(r.y)) - view.origin[1]); break paneFrame(s, view.serial, r); } else .{}; const at = p.cursor_glide.centreAt(p.now_ns); const seen = frame.unplace(old.place(at)); p.cursor_glide.shift(seen[0] - at[0], seen[1] - at[1]); p.cursor_crossing = true; } p.cursor_view = .{ .serial = pane.serial, .line = line, .origin = origin, .inside = inside }; } p.cursor_glide.aim(box, p.now_ns, motion, snap); _ = p.cursor_glide.step(p.now_ns); const moving = p.cursor_glide.moving(); if (!moving) p.cursor_crossing = false; // Drawn as the quad while it glides and while its pane moves. s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving or frame.moving())); // The shell blinks it, from how long ago the last key or click was. if (p.settings.cursor_blink) s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms)); // A little less than solid while it glides, so the glyphs it passes // stay legible (less on the long flavours), solid again as it lands. const remaining = p.cursor_glide.remaining(p.now_ns); const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85; s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1; const quad = p.cursor_glide.sample(p.now_ns, motion); // Never outside the focused pane as drawn, however far a flavour // overshoots or a scroll carried it: it squashes against the pane's // edges. Crossing to another pane, it may pass over any of the grid. const bounds: animation.Box = if (p.cursor_crossing or !inside) .{ .w = @floatFromInt(s.cols), .h = @floatFromInt(s.rows) } else frame.at; for (quad, 0..) |corner, i| { const point = frame.place(corner); s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], bounds.x, bounds.x + bounds.w); s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], bounds.y, bounds.y + bounds.h); } } /// Where a pane is drawn against where the layout puts it: its transition /// track's presented box against the track's target, or the layout's box /// both ways when it is still. Maps grid points between the two. const PaneFrame = struct { to: animation.Box = .{ .w = 1, .h = 1 }, at: animation.Box = .{ .w = 1, .h = 1 }, fn moving(f: PaneFrame) bool { return !std.meta.eql(f.to, f.at); } fn place(f: PaneFrame, point: [2]f32) [2]f32 { return .{ f.at.x + (point[0] - f.to.x) * scale(f.at.w, f.to.w), f.at.y + (point[1] - f.to.y) * scale(f.at.h, f.to.h) }; } fn unplace(f: PaneFrame, point: [2]f32) [2]f32 { return .{ f.to.x + (point[0] - f.at.x) * scale(f.to.w, f.at.w), f.to.y + (point[1] - f.at.y) * scale(f.to.h, f.at.h) }; } fn scale(num: f32, den: f32) f32 { return if (den > 0.001) num / den else 1; } }; fn paneFrame(s: *const Surface, serial: u32, rect: Rect) PaneFrame { const still = layout.panelBox(rect); for (s.panelTracks()) |track| { if (track.serial != serial or track.phase == .closing or !track.active()) continue; return .{ .to = track.to, .at = track.visualBox() }; } return .{ .to = still, .at = still }; } /// The Lift style as drawn: `auto` is a shadow on a light page, and on a /// dark one, where a shadow has nothing to darken, no lift at all (the other /// panes recede instead, `inactiveDim`). pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle { return switch (p.settings.lift) { .auto => if (darkPage(p)) .off else .shadow, else => |style| style, }; } /// How far, in percent, the unfocused panes' text fades toward its ground: /// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that /// is unset. pub fn inactiveDim(p: *const Pardes) u8 { if (p.settings.inactive_dim != 0) return p.settings.inactive_dim; return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0; } fn darkPage(p: *const Pardes) bool { return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true; } /// sRGB channel to linear light. const linear: [256]f32 = blk: { @setEvalBranchQuota(100_000); var table: [256]f32 = undefined; for (&table, 0..) |*out, i| { const c = @as(f64, @floatFromInt(i)) / 255; out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4)); } break :blk table; }; fn luminance(rgb: [3]u8) f32 { return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]]; } fn contrast(a: [3]u8, b: [3]u8) f32 { const x = luminance(a); const y = luminance(b); return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); } /// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid /// and on every layer of it: its tag, its body, its notices. Its grip keeps /// its colour, which already says it is not the focused one. The focused /// pane, still brightening, is faded in its body's text alone: its tag and /// a selection are focus indicators, which no effect may alter. fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32, focused: bool) void { const th = p.theme(); const chrome = p.chromeTheme(); const r = p.rects[id]; const palette = th.palette; var memo: Memo = .{}; const text_x = r.x + @min(config.GUTTER, r.w); var row = r.y; while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) { var col = text_x; while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) { const cell = s.at(col, row); if (cell.default) continue; const tagline_role = cell.style.font_role == .tagline; if (focused and (tagline_role or selected(cell, th.sel_bg))) continue; fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo); } } const body = &s.body_layers[id]; if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| { if (!(focused and selected(cell, th.sel_bg))) fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo); }; if (focused) return; const tag = &s.tag_layers[id]; if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo); for (0..Pane.Notices.max) |i| { const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo); } } fn selected(cell: *const Cell, sel_bg: [3]u8) bool { return switch (cell.style.bg) { .rgb => |rgb| std.mem.eql(u8, &rgb, &sel_bg), else => false, }; } /// The few ink and paper pairs a pane has, each faded once a frame. const Memo = struct { keys: [32]?[6]u8 = @splat(null), values: [32][3]u8 = undefined, fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 { const key = ink ++ paper; const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len; if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot]; // Contrast falls as the ink nears its paper: the most of `amount` // that keeps the pair at its own contrast or 4.5, whichever is lower. const floor = @min(contrast(ink, paper), 4.5); var t = amount; if (contrast(blend(ink, paper, t), paper) < floor) { var lo: f32 = 0; var hi: f32 = t; for (0..8) |_| { const mid = (lo + hi) / 2; if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid; } t = lo; } memo.keys[slot] = key; memo.values[slot] = blend(ink, paper, t); return memo.values[slot]; } }; /// A cell's ink blended `amount` of the way toward its paper, never below /// the pair's own contrast or 4.5, whichever is lower (docs/effects.md): /// faint resolved first, reverse honoured, as InactiveDim always did. fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void { if (style.invisible) return; var fg = resolved(style.fg, default_fg, palette) orelse return; const bg = resolved(style.bg, default_bg, palette) orelse return; if (style.dim) for (&fg) |*channel| { channel.* = @intCast(@as(u16, channel.*) * 6 / 10); }; const ink = if (style.reverse) bg else fg; const paper = if (style.reverse) fg else bg; const rgb = memo.faded(ink, paper, amount); if (style.reverse) { style.bg = .{ .rgb = rgb }; if (style.dim) style.fg = .{ .rgb = fg }; } else style.fg = .{ .rgb = rgb }; style.dim = false; } /// `t` of the way from one colour to the other in linear light (§8.2), so a /// coloured ink does not muddy on its way to the paper. fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 { var out: [3]u8 = undefined; for (&out, from, to) |*o, a, b| { const l = linear[a] * (1 - t) + linear[b] * t; o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))]; } return out; } /// Linear light back to an sRGB channel, in 4096 steps. const encoded: [4096]u8 = blk: { @setEvalBranchQuota(1_000_000); var table: [4096]u8 = undefined; for (&table, 0..) |*out, i| { const l = @as(f64, @floatFromInt(i)) / 4095; const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055; out.* = @intFromFloat(@round(c * 255)); } break :blk table; }; fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 { return switch (color) { .rgb => |rgb| rgb, .default => default, // A theme's own sixteen, as the painters give them (Terminal.palColor). .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: { const rgb = @import("ghostty-vt").color.default[index]; break :blk .{ rgb.r, rgb.g, rgb.b }; } else null, }; } 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; if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; s.npanel_tracks = 0; 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, .accent = chrome.box, .sel_fg = p.theme().sel_fg, .sel_bg = p.theme().sel_bg, .lift_style = @intFromEnum(liftStyle(p)), .rule_px = p.theme().rule_px orelse 2, .grip_border = boxBorder(p), .grip_focus_ring = chrome.box, .grip_dirty = boxDirty(p), .tag_rule = p.theme().tag_rule, .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), .name_ink = chrome.tag_name_fg, .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg, .fx = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1 | @as(u8, @intFromBool(p.settings.occlusion)) << 2 | @as(u8, @intFromBool(p.settings.parallax)) << 3, }; if (p.theme().decor) |decor| { s.chrome.decor_dots = decor.page_dots; s.chrome.decor_dot_alpha = decor.page_dot_alpha; s.chrome.decor_dot_px = decor.page_dot_px; s.chrome.decor_checker = decor.rail_checker; s.chrome.decor_checker_px = decor.rail_checker_px; s.chrome.decor_box_border = decor.tag_border; s.chrome.decor_box_border_px = decor.tag_border_px; s.chrome.decor_box_shadow = decor.tag_shadow; s.chrome.decor_box_shadow_px = decor.tag_shadow_px; s.chrome.decor_box_stripe = decor.tag_stripe; s.chrome.decor_box_stripe_px = decor.tag_stripe_px; s.chrome.decor_title = decor.title_shadow; s.chrome.decor_title_px = decor.title_shadow_px; } place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } }); for (0..p.ncol) |c| if (p.col_n[c] == 0) s.fill(p.col_x[c], p.topBarHeight() + p.columnBarHeight(), p.col_w[c], s.rows -| (p.topBarHeight() + p.columnBarHeight()), .{ .bg = .{ .rgb = chrome.empty_col } }); 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; try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); } // Each notice once, as a tag layer, and the grid's chip a copy of it. for (s.regionList()) |region| { if (region.kind == .notice) try joinNotice(p, s, arena, region); } // Both ways on the same spring: the pane focus leaves dims as the one // it reaches brightens, so a focus change never pops (Motion off snaps // both). for (p.panes, p.lifts, 0..) |slot, spring, id| { if (slot == null) continue; const focus = spring.value(p.now_ns) / animation.Motion.of(p.settings.motion).gain; const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - focus, 0, 1); if (amount > 0.001) recede(p, s, id, amount, id == p.active); } try paintHeaders(p, s, 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, "╎"); 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) { 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 } }); }; }, // A column that went away mid-drag has nothing to guide. .border_h => |d| if (d.cur_y < s.rows and d.col < p.ncol and p.col_x[d.col] < s.cols) { const end = @min(s.cols, p.col_x[d.col] + p.col_w[d.col]); var col = p.col_x[d.col]; while (col < end) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = end - p.col_x[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; if (s.cols == 0) 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, }; // 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. // 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(); // 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) { 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| if (placement.row < s.rows and p.col_x[placement.preview_col] + p.col_w[placement.preview_col] <= s.cols) { 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 } }); 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 } }); }; }, // A hover left over from a bigger window points past this one. .none, .select => if (p.pointer_inside and p.hover_row < s.rows and p.hover_col < s.cols) { // 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, "╎"); s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } } // No hint over a pane's last row: a row of text is no handle; two // panes resize by the rule between them or by the grip. 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, "╌"); s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } }); } }, } // 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) { 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(), "╮", .{}); 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 } }); } } }; // After the tracks are submitted: the cursor is drawn through its // pane's. try p.presentation.submit(p, s); glideCursor(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), .edge => |weight| towardGround(canonical.cells[index], canonical.previous_cells[index].style.bg, weight), // 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]; }, }; } /// A cell with its ink and ground drawn `weight` of the way toward another /// ground, in OKLab. A palette or default colour cannot be mixed, so the /// ground steps over at the halfway point instead. fn towardGround(cell: Cell, ground: Color, weight: f32) Cell { var out = cell; const to = switch (ground) { .rgb => |rgb| rgb, .default, .index => return if (weight >= 0.5) blk: { out.style.bg = ground; break :blk out; } else out, }; inline for (.{ &out.style.fg, &out.style.bg }) |color| switch (color.*) { .rgb => |rgb| color.* = .{ .rgb = colors.mixOklab(rgb, to, weight) }, .default, .index => {}, }; return out; } 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, s: *Surface, 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 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 } }); } // The grip is acme's button: the focused pane's filled, the others' // a ring round the tag's ground, a dirty one's the dirty colour inside // its ring. A pixel shell draws the ring (gui.zig gripDecor); a grid, // which cannot, marks dirty in the grip's second cell with a bold `*` // on the dirty colour, a cue that survives any palette. const band = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const dirty = tagline.dirty(pane); const box_bg = gripBg(p, active); const box_style: CellStyle = .{ .fg = .{ .rgb = inkOn(box_bg) }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; // On the first row alone, as acme's button is; the rows under it are // the tag band's, and still grab the pane (the region spans them all, // as acme's button column does). s.fill(r.x, tag_y, config.GUTTER, @min(1, pane.tag_rows), box_style); if (dirty and pane.tag_rows > 0 and config.GUTTER > 1) s.set(r.x + config.GUTTER - 1, tag_y, "*", .{ .fg = .{ .rgb = inkOn(boxDirty(p)) }, .bg = .{ .rgb = boxDirty(p) }, .bold = true, .font_role = .tagline }); s.fill(r.x, tag_y + 1, config.GUTTER, pane.tag_rows -| 1, .{ .bg = .{ .rgb = band }, .font_role = .tagline, }); 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 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. 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, rail)) 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, s, 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(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.joinBody(p, s, arena, pane, r, id, active, rail.*); } /// 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(); // 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); if (s.region(.workspace_tag, Region.none)) |region| { const top = if (p.header_focus and p.header_column == null) p.header_top else 0; 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| { 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, @min(1, column_rows), .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }); s.fill(p.col_x[c], region.rect.y + 1, grip, column_rows -| 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); 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; 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'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); 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.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.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()) { // `tag` is the first row; a prefix longer than it ran to a second. var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n"); while (words.next()) |word| { if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(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..@min(prefix_len, tag.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 }; } /// 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; if (viewport.w == 0 or viewport.h == 0) { // No layer, and nothing of an earlier frame's left in it. layer.rows = 0; layer.viewport = viewport; layer.cursor = null; return; } 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.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; // 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, .body => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; 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 }); // The scroll is the caret's line's: a shorter line shows nothing. _ = surface.print(0, 0, columns, shown[@min(shown.len, panes.File.rawAtDisplay(shown, scroll))..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); // 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| (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().tag_sel_bg orelse 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().tag_sel_bg orelse 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 }; } } } /// 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. 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.shownTag(p, arena, pane)).text; const rows = pane.tag_rows; // As wide as the widest row 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 }; // 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| { @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); } // Past a full row's end the layer's caret is at the tag's edge, as // acme's tick is; the grid has no cell there, so it takes the last one. if (temporary.cursor) |cursor| { s.cursor = .{ .x = r.x + @min(cursor.x, r.w - 1), .y = region.rect.y + cursor.y, .bar = cursor.bar }; } } /// 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); // Where an exec was sent to a REPL reads in the tag's name tint, set // apart from what the editor says. const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg; const fg = Messages.blendRgb(ink, 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 or pane.prompt == .repl_choice) 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 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. if (kind == .prompt and id == p.active and pane.prompt != .del_side and pane.prompt != .repl_choice) 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 = .{ .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). 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). // A notice wider than its band has less padding than the band hides. for (skip..@max(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.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; } pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { 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; // Its lines wrapped at its width (tagline.shownTag): a row per row shown. const shown = try tagline.shownTag(p, arena, pane); var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); var split = std.mem.splitScalar(u8, shown.text, '\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) caret: { // A drafted name is the prefix: its caret is on row 0 of the tag, // the rows it wrapped onto included. const at = modal.positionAt(shown.text, shown.fromReal(@intCast(@max(0, pane.input.cur_col)))); if (at.row < top or at.row >= top + rows) break :caret null; break :caret .{ .row = at.row - top, .at = @min(lines[at.row - top].len, at.col) }; } else if (pane.focus == .tag and pane.prompt == .none) caret: { const real = modal.offsetAt(whole, .{ .row = @intCast(@max(0, pane.tag.cur_row)), .col = @intCast(@max(0, pane.tag.cur_col)) }); const at = modal.positionAt(shown.text, shown.fromReal(real)); if (at.row < top or at.row >= top + rows) break :caret null; const row: usize = at.row - top; break :caret .{ .row = row, .at = @min(lines[row].len, at.col) }; } else null; // Its rows are wrapped to fit: a pane tag never scrolls sideways. _ = capacity; pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { // The scroll is the caret's line's: a shorter row shows nothing. const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll)); _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); } // The name's tint, in the tag's first line, over whichever rows it // wrapped onto. const first_line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; if (tagTintRange(pane, first_line, prefix_len)) |name| if (name.lo < name.hi) { const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; const a = modal.positionAt(shown.text, shown.fromReal(name.lo)); const b = modal.positionAt(shown.text, shown.fromReal(@min(name.hi, first_line.len))); var row = @max(a.row, top); while (row <= b.row and row < top + rows) : (row += 1) { const line = lines[row - top]; const from = if (row == a.row) a.col else 0; const to = if (row == b.row) b.col else line.len; var col = panes.File.rawDisplayCol(line, @min(from, line.len)); const end = panes.File.rawDisplayCol(line, @min(to, line.len)); const y = tag_y + @as(u16, @intCast(row - top)); while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), 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, 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 // 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(shown.text, shown.fromReal(lo)); const last = modal.positionAt(shown.text, shown.fromReal(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.tag_sel_bg orelse 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 }; } // Pointer sweeps over the tag rows. A select sweep is the tag's own, in // its selection colours; the look-hover affordance goes over it, and a // middle or right sweep over both, tinted by its button the way the // body tints it. for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.tag_sel_bg orelse th.sel_bg, th.sel_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, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); }; } const sel_btn = body_layer.buttonTints(th); const sel_ink = body_layer.buttonInks(th); for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], sel_ink[button]); } } test "an unfocused grip shows on its tag in the grid, in every native theme" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); p.animate_theme_changes = false; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for (pardes.themes[0..colors.native_count], 0..) |theme, index| { colors.setThemeIndex(p, index); p.active = 1; _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const r = p.rects[0]; const grip = s.at(r.x, p.tagTop(p.panes[0].?, r)).style.bg.rgb; const ratio = contrast(grip, p.chromeTheme().tag_bg); // ponytail: dark and helix have always had a faint grip (1.2:1); flagged, not changed here. const floor: f32 = if (std.mem.eql(u8, theme.name, "dark") or std.mem.eql(u8, theme.name, "helix")) 1.2 else 1.5; if (ratio < floor) std.debug.print("{s}: grip {d:.2}\n", .{ theme.name, ratio }); try std.testing.expect(ratio >= floor); } } test "a grip's four states, focused or not and clean or dirty, are each its own in every native theme" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); defer p.deinit(); const pane = try p.setTestFile("text\n"); p.newScratchBelow(0); p.animate_theme_changes = false; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const State = struct { first: Cell, second: Cell, dirty: bool }; for (0..colors.native_count) |index| { colors.setThemeIndex(p, index); var states: [4]State = undefined; for (&states, 0..) |*state, i| { p.active = if (i & 1 == 0) 0 else 1; const saved = pane.file.?.saved_revision; pane.file.?.revision = if (i & 2 != 0) saved + 1 else saved; _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const r = p.rects[0]; const y = p.tagTop(pane, r); state.* = .{ .first = s.at(r.x, y).*, .second = s.at(r.x + 1, y).*, .dirty = s.region(.grip, 0).?.dirty }; try std.testing.expectEqual(i & 2 != 0, state.dirty); if (i & 2 == 0) continue; // Dirty is more than a shade: the grid prints a bold `*` (one // grid for every shell; a pixel shell draws its button over it). try std.testing.expectEqualStrings("*", state.second.grapheme()); try std.testing.expect(state.second.style.bold); } for (states, 0..) |a, i| for (states[i + 1 ..]) |b| { const same = std.meta.eql(a.first.style.bg, b.first.style.bg) and a.second.visuallyEqual(&b.second); try std.testing.expect(!same); }; } pane.file.?.revision = pane.file.?.saved_revision; } test "every theme draws a file's name, a +Errors and a Tty word in a tint, never the tag's plain ink" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 120, .rows = 40, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("text\n"); try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); const terminal = p.active; p.acknowledgeShell(terminal, "/bin/sh", false); try @import("Output.zig").openErrors(p, 0, try gpa.dupe(u8, "oops\n")); while (p.nextEffect()) |_| {} p.animate_theme_changes = false; const errors = for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.file) |f| if (std.mem.endsWith(u8, f.path, "+Errors")) break id; } else return error.NoErrorsPane; const Probe = struct { id: usize, word: []const u8 }; const probes = [_]Probe{ .{ .id = 0, .word = "test.txt" }, .{ .id = terminal, .word = "Tty+sh" }, .{ .id = errors, .word = "+Errors" } }; var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); for (pardes.themes, 0..) |theme, index| { colors.setThemeIndex(p, index); // Each pane focused in turn: every word on both grounds. for (probes) |focused| { p.active = focused.id; _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); for (probes) |probe| { const r = p.rects[probe.id]; const y = p.tagTop(p.panes[probe.id].?, r); // The row's text, and the column each byte of it is drawn at. var row: [512]u8 = undefined; var cols: [512]u16 = undefined; var len: usize = 0; for (r.x..r.x + r.w) |x| { const g = s.at(@intCast(x), y).grapheme(); for (if (g.len == 0) " " else g) |byte| if (len < row.len) { row[len] = byte; cols[len] = @intCast(x); len += 1; }; } const name = std.mem.indexOf(u8, row[0..len], probe.word) orelse return error.WordNotDrawn; const plain = std.mem.indexOf(u8, row[0..len], " Del") orelse return error.WordNotDrawn; const tint = s.at(cols[name], y).style.fg; const ink = s.at(cols[plain + 1], y).style.fg; if (std.meta.eql(tint, ink)) std.debug.print("{s}: {s} drawn in the tag's ink\n", .{ theme.name, probe.word }); try std.testing.expect(!std.meta.eql(tint, ink)); // The whole word, in the one tint. try std.testing.expect(std.meta.eql(s.at(cols[name + probe.word.len - 1], y).style.fg, tint)); } } } } test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); const pane = try p.setTestFile("text\n"); pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird\nfourth"); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for ([_]bool{ false, true }) |bottom| { p.settings.tag_bottom = bottom; p.sync(); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const r = p.rects[0]; const top = p.tagTop(pane, r); try std.testing.expect(pane.tag_rows >= 3); const chrome = p.chromeTheme(); const band = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; for (0..config.GUTTER) |dx| { const x = r.x + @as(u16, @intCast(dx)); try std.testing.expectEqual(Color{ .rgb = chrome.box }, s.at(x, top).style.bg); for (1..pane.tag_rows) |line| { const cell = s.at(x, top + @as(u16, @intCast(line))); try std.testing.expectEqual(Color{ .rgb = band }, cell.style.bg); try std.testing.expectEqualStrings(" ", cell.grapheme()); } } try std.testing.expectEqual(pane.tag_rows, s.region(.grip, 0).?.rect.h); } } 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.newColumn(0); p.newScratchIn(0, p.activeColumn().?); 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; 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); // 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); 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); } } } 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 notice wider than its band, as a shader's compile error is, draws its end and nothing past it" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20 }); defer p.deinit(); // A gui-shaped core: its tagline is narrower than the body, so a notice's // band holds more columns than its chip has cells. p.update(.{ .resize = .{ .cols = 80, .rows = 20, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); _ = try p.setTestFile("text\n"); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const long = "Shader /home/someone/shaders/test_shadertoy_invalid.glsl: error: Linking fragment stage: No function definition (body) found: " ** 3; for ([_][]const u8{ "Msg " ++ long, "" }) |line| { // The Msg builtin, and the path a failed compile takes (Post.zig). if (line.len > 0) try std.testing.expect(p.executeBuiltinLine(p.active, line)) else p.setMessage(p.active, long); p.sync(); for (0..30) |_| p.update(.tick); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const notice = for (s.regionList()) |region| { if (region.kind == .notice) break region; } else return error.TestExpectedNotice; // Text up to the chip's blank last cell, and no padding ink. const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][notice.rect.x..][0..notice.rect.w]; try std.testing.expectEqualStrings(" ", row[row.len - 1].grapheme()); try std.testing.expect(!std.mem.eql(u8, " ", row[row.len - 2].grapheme())); } } test "a lift is sampled at each frame's own time, and asks for frames only while it moves" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); // A grid has no in-between: there a lift snaps. if (!builtins.capabilities.lift) return error.SkipZigTest; _ = try p.setTestFile("text\n"); p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; p.settings.lift = .shadow; p.advance(1000 * ms); _ = try p.render(arena.allocator()); p.advance(3000 * ms); _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); p.active = 0; var lifts: [3]f32 = undefined; // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own. for (&lifts, 0..) |*lift, i| { p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); lift.* = for (s.regionList()) |region| { if (region.kind == .grip and region.owner == p.active) break region.lift; } else return error.TestExpectedGrip; try std.testing.expect(p.continuous()); } try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]); // Settled, it is exactly up, and the core is idle again. p.advance(5000 * ms); _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } /// The contrast a pane's first body glyph `g` is drawn at, on the grid. fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 { const r = p.rects[id]; const th = p.theme(); var row = p.bodyTop(p.panes[id].?, r); while (row < r.y + r.h) : (row += 1) { var col = r.x + config.GUTTER; while (col < r.x + r.w) : (col += 1) { const cell = s.at(col, row); if (cell.default or cell.len != 1 or cell.text[0] != g) continue; var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?; const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?; if (cell.style.dim) for (&fg) |*channel| { channel.* = @intCast(@as(u16, channel.*) * 6 / 10); }; return contrast(fg, bg); } } unreachable; } test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); p.animate_theme_changes = false; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const s_ns = std.time.ns_per_s; var now: u64 = s_ns; for (0..pardes.themes.len) |theme| { colors.setThemeIndex(p, theme); for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| { p.settings.lift = style; p.settings.inactive_dim = dim; var seen: [2]f32 = undefined; for (&seen, [_]usize{ 0, 1 }) |*c, focus| { p.active = focus; _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); now += 2 * s_ns; p.advance(now); _ = arena.reset(.retain_capacity); c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't'); } try std.testing.expect(seen[0] >= seen[1] - 1e-3); // And the unfocused one is still legible. try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2); }; } } test "a dim leaves the focused pane's tag, grip and cells exactly as they were" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.active = 0; p.advance(std.time.ns_per_s); const plain = try p.render(arena.allocator()); const r = p.rects[0]; var before: std.ArrayList(Cell) = .empty; defer before.deinit(std.testing.allocator); for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]); const tag_bg = plain.tag_layers[0].bg; const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]); defer std.testing.allocator.free(tag_cells); p.settings.inactive_dim = 100; p.advance(3 * std.time.ns_per_s); const dimmed = try p.render(arena.allocator()); var i: usize = 0; for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| { try std.testing.expect(cell.visuallyEqual(&before.items[i])); i += 1; }; try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg); for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b)); } test "a dim never takes a pair below its own contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse continue; const bg = theme.bg orelse continue; for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| { var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint }; var ink = fg; if (faint) for (&ink) |*channel| { channel.* = @intCast(@as(u16, channel.*) * 6 / 10); }; var memo: Memo = .{}; fadeStyle(&style, null, null, null, 1, &memo); const after = contrast(style.fg.rgb, style.bg.rgb); try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2); }; } } test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" { var memo: Memo = .{}; var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } }; fadeStyle(&low, null, null, null, 1, &memo); try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb); // acme's bright white is black: faded, it stays a grey, never a white. const acme = for (pardes.themes) |theme| { if (std.mem.eql(u8, theme.name, "acme")) break theme; } else unreachable; var bright: CellStyle = .{ .fg = .{ .index = 15 } }; memo = .{}; fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo); const rgb = bright.fg.rgb; try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0); // Reverse: the paper moves, the ink stays. var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true }; memo = .{}; fadeStyle(&reverse, null, null, null, 0.3, &memo); try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb); try std.testing.expect(reverse.bg.rgb[0] < 255); } test "with no lift, no dim and no motion a focus switch asks for no frames" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); // With motion the cursor glides to the other pane (glideCursor). p.settings.motion = .off; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.advance(std.time.ns_per_s); _ = try p.render(arena.allocator()); p.active = 0; _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); } test "each Motion flavour reaches the lift as its own trajectory" { // A grid snaps; the flavours are a pixel shell's. if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.settings.lift = .shadow; var peaks: [5]f32 = undefined; const ms = std.time.ns_per_ms; var start: u64 = std.time.ns_per_s; for (std.enums.values(animation.Motion.Flavour), &peaks) |flavour, *peak| { p.settings.motion = flavour; p.active = 1; p.advance(start); _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); start += 3 * std.time.ns_per_s; p.advance(start); _ = try p.render(arena.allocator()); p.active = 0; peak.* = 0; var t: u64 = 0; while (t <= 1500 * ms) : (t += 4 * ms) { p.advance(start + t); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const lift = for (s.regionList()) |region| { if (region.kind == .grip and region.owner == 0) break region.lift; } else unreachable; peak.* = @max(peak.*, lift); } start += 3 * std.time.ns_per_s; } // Bouncy and playful pass their (exaggerated) mark; smooth never does. try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]); } test "the focused cursor glides a jump, lands a step at once, and is exact at rest" { if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("some text on a line\n" ** 20); p.settings.cursor_blink = false; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; var now: u64 = std.time.ns_per_s; p.advance(now); _ = try p.render(arena.allocator()); // A step down: at once. p.update(.{ .key = .{ .cp = 'j' } }); now += 16 * ms; p.advance(now); _ = arena.reset(.retain_capacity); var s = try p.render(arena.allocator()); try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide); const before = s.cursor.?; // To the last line: it glides, a quad partway, and asks for frames. p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'e' } }); now += 16 * ms; p.advance(now); _ = arena.reset(.retain_capacity); s = try p.render(arena.allocator()); const target = s.cursor.?; try std.testing.expect(target.y > before.y + 5); try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide); try std.testing.expect(s.chrome.cursor_quad[1] < @as(f32, @floatFromInt(target.y)) - 1); try std.testing.expect(p.continuous()); // Frame by frame to rest: gliding exactly while it moves (the shell then // draws the quad, never the cells' cursor too), at under full alpha // until it lands, solid as it does. var frames: usize = 0; while (p.continuous()) : (frames += 1) { now += 7 * ms; p.advance(now); _ = arena.reset(.retain_capacity); s = try p.render(arena.allocator()); try std.testing.expect(s.chrome.cursor_glide & 2 != 0); try std.testing.expectEqual(p.cursor_glide.moving(), s.chrome.cursor_glide & 1 != 0); if (s.chrome.cursor_glide & 1 != 0) try std.testing.expect(s.chrome.cursor_alpha <= 1 and s.chrome.cursor_alpha >= 0.7); for (s.chrome.cursor_quad) |value| try std.testing.expect(std.math.isFinite(value)); } try std.testing.expect(frames > 3); try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide); try std.testing.expectEqual(@as(f32, 1), s.chrome.cursor_alpha); // At rest the quad is the cursor's own cell. try std.testing.expectEqual(@as(f32, @floatFromInt(target.x)), s.chrome.cursor_quad[0]); try std.testing.expectEqual(@as(f32, @floatFromInt(target.y)), s.chrome.cursor_quad[1]); // A mode change mid-glide lands at once: insert mode never trails. p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'g' } }); now += 7 * ms; p.advance(now); _ = arena.reset(.retain_capacity); s = try p.render(arena.allocator()); try std.testing.expectEqual(@as(u8, 3), s.chrome.cursor_glide); p.update(.{ .key = .{ .cp = 'i' } }); now += 7 * ms; p.advance(now); _ = arena.reset(.retain_capacity); s = try p.render(arena.allocator()); try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide); } test "a tag caret past its row's end glides to the tag's right edge, where the layer's is" { if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); const pane = try p.setTestFile("text\n"); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); // The tag's text grown a letter at a time, the caret after its last one // in insert mode, until a row is full and the caret is past its end. var found = false; var n: usize = 1; while (n < 200 and !found) : (n += 1) { _ = arena.reset(.retain_capacity); if (pane.tag.own) |own| p.gpa.free(own); const own = try p.gpa.alloc(u8, n); @memset(own, 'x'); own[0] = ' '; pane.tag.own = own; tagline.enterTag(p, pane); const whole = try tagline.tagText(p, arena.allocator(), pane); pane.tag.mode = .insert; pane.tag.cur_row = 0; pane.tag.cur_col = @intCast(modal.lineSlice(whole, 0).len); p.sync(); const s = try p.render(arena.allocator()); const layer = for (s.tagLayers()) |*layer| { if (layer.rows != 0 and layer.kind == .pane and layer.cursor != null) break layer; } else continue; const caret = layer.cursor.?; const right: f32 = @floatFromInt(layer.viewport.x + layer.viewport.w); const box = focusedCursorBox(p, s).?; // Never past the tag's right edge, and the pane's. try std.testing.expect(box.x + box.w <= right + 1e-4); try std.testing.expect(box.x + box.w <= @as(f32, @floatFromInt(p.rects[p.active].x + p.rects[p.active].w))); const at = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * 6 / 10; if (at + box.w <= right) { // Inside its row: exactly where the layer's caret is. try std.testing.expectApproxEqAbs(at, box.x, 1e-4); } else { // Past it: on the tag's right edge, as the pixel shell's caret // is (gui.zig cursorBoxes). try std.testing.expectApproxEqAbs(right, box.x + box.w, 1e-4); try std.testing.expectApproxEqAbs(@as(f32, 6.0 / 10.0 / 8.0), box.w, 1e-4); found = true; } } try std.testing.expect(found); } test "a terminal's cursor lands with scrolling output, never gliding against it" { if (!builtins.capabilities.lift or !pardes.terminal_panes) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12 }); defer p.deinit(); while (p.nextEffect()) |_| {} const term = p.active; try std.testing.expect(p.panes[term].?.isTerminal()); p.settings.cursor_blink = false; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; var now: u64 = std.time.ns_per_s; p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "line\r\n" ** 20 ++ "$ some command typed" } }); p.advance(now); _ = try p.render(arena.allocator()); // Enter: three lines of output scroll up, the cursor to a new prompt. p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\r\nout\r\nout\r\nout\r\n$ " } }); now += 16 * ms; p.advance(now); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide); } test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { // The snapshot harness runs the tty, whose tagline pitch is the body's, // so it never reaches what a narrower tagline does to a notice's band: // this drives the core at a gui's metrics instead. PARDES_FUZZ_STEPS // runs it longer (the suite's run is short). const steps: usize = if (std.c.getenv("PARDES_FUZZ_STEPS")) |v| std.fmt.parseInt(usize, std.mem.span(v), 10) catch 400 else 400; const seed: u64 = if (std.c.getenv("PARDES_FUZZ_SEED")) |v| std.fmt.parseInt(u64, std.mem.span(v), 10) catch 7 else 7; var prng = std.Random.DefaultPrng.init(seed); const random = prng.random(); const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("fuzz\n\u{754c}\u{754c} wide\n" ** 20); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " }; const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; var text: std.ArrayListUnmanaged(u8) = .empty; defer text.deinit(std.testing.allocator); for (0..steps) |_| { switch (random.uintLessThan(u8, 8)) { // A resize, down to a cell, at a gui's metrics: a tagline no // wider and no taller than the body's cell, often narrower. 0 => { const body_w = random.intRangeAtMost(u16, 6, 12); const body_h = random.intRangeAtMost(u16, 12, 24); const cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 12) else random.intRangeAtMost(u16, 13, 140); const rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 50); p.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = body_w, .body_h = body_h, .tagline_w = random.intRangeAtMost(u16, 2, body_w), .tagline_h = random.intRangeAtMost(u16, 6, body_h) } } }); }, // A message, as long as a compile error or longer. 1, 2 => { text.clearRetainingCapacity(); for (0..random.uintAtMost(usize, 60)) |_| try text.appendSlice(std.testing.allocator, words[random.uintLessThan(usize, words.len)]); if (random.boolean()) p.setMessage(p.active, text.items) else { const line = try std.fmt.allocPrint(arena.allocator(), "Msg {s}", .{text.items}); _ = p.executeBuiltinLine(p.active, line); } }, 3 => _ = p.executeBuiltinLine(p.active, commands[random.uintLessThan(usize, commands.len)]), // Typing, escaping and entering, into a prompt when one is up. 4 => p.update(.{ .key = .{ .cp = switch (random.uintLessThan(u8, 9)) { 0 => pardes.Key.escape, 1 => pardes.Key.enter, 2 => 0x754c, 3 => pardes.Key.end, 4 => pardes.Key.home, 5 => pardes.Key.up, 6 => pardes.Key.down, else => 'a' + random.uintLessThan(u21, 26), } } }), // A tag of several rows. 5 => if (p.panes[p.active]) |pane| { text.clearRetainingCapacity(); for (0..random.uintAtMost(usize, 5)) |_| try text.appendSlice(std.testing.allocator, if (random.boolean()) " Look\nmore \u{754c}" else "\n" ++ "x" ** 150); if (pane.tag.own) |own| std.testing.allocator.free(own); pane.tag.own = try std.testing.allocator.dupe(u8, text.items); }, // Hover, and clicks: into a tag, a header, a body, a grip. 6 => { const col = random.uintAtMost(u16, p.screen_w); const row = random.uintAtMost(u16, p.screen_h); if (random.boolean()) p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }) else { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col, .row = row } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col, .row = row } }); } }, else => for (0..random.uintAtMost(usize, 20)) |_| p.update(.tick), } p.sync(); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); p.acknowledgePanelPresentation(s.panelTracks()); } } 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); } } } test "a body with context rows is painted once, and the grid's rows are what a plain paint of it gives" { if (comptime !@import("syntax.zig").enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 }); defer p.deinit(); const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body line\n" ** 30) ++ " }\n};\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/test.zig"); if (!panes.File.supportsContext(pane)) return error.SkipZigTest; pane.file.?.tree_context = true; pane.file.?.scroll = 8; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for ([_]bool{ false, true }) |bottom| { p.settings.tag_bottom = bottom; // A selection and the cursor on body rows, painted in the one pass. pane.body.cur_row = 12; pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .row = 10, .col = 2 }; pane.body.mode = .normal; p.sync(); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); const layer = &s.body_layers[0]; try std.testing.expect(layer.context_rows > 0); const body = s.region(.body, 0).?; // The plain paint the grid used to take, of the same frame. const cells = try arena.allocator().alloc(Cell, s.cells.len); @memset(cells, .{}); var plain: Surface = .{ .cols = s.cols, .rows = s.rows, .cells = cells }; const saved = .{ pane.body_rows, pane.body_visible_rows, pane.context_row_limit }; pane.body_rows = 0; pane.body_visible_rows = 0; pane.context_row_limit = null; try body_layer.renderBody(p, &plain, arena.allocator(), pane, p.rects[0], 0, true, null, null); pane.body_rows, pane.body_visible_rows, pane.context_row_limit = saved; for (0..body.rect.h) |row| { const y = body.rect.y + row; for (0..body.rect.w) |col| { const x = body.rect.x + col; const got = s.cells[y * s.cols + x]; const want = plain.cells[y * s.cols + x]; try std.testing.expect(got.visuallyEqual(&want)); } } try std.testing.expectEqual(plain.cursor, s.cursor); // The layer's own rows run past the grid's. try std.testing.expect(layer.rows > body.rect.h); } } /// Two columns, the file in the left one and a shell in the right, the /// right one focused with its cursor settled. fn twoColumns(p: *Pardes, arena: *std.heap.ArenaAllocator) !void { _ = try p.setTestFile("some text on a line\n" ** 6); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); p.advance(std.time.ns_per_s); const boot = try p.render(arena.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.advance(2 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); const settled = try p.render(arena.allocator()); p.acknowledgePanelPresentation(settled.panelTracks()); try std.testing.expect(!p.cursor_glide.moving()); } test "while its pane slides the cursor rides with it, at the same cell of the pane every frame" { if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.settings.motion = .bouncy; try twoColumns(p, &arena); p.active = 1; p.advance(3 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); p.advance(4 * std.time.ns_per_s); p.settings.panel_transition = .slide; // The right column's pane moves right. p.col_weight[0] = layout.column_weight_unit * 2; p.col_weight[1] = layout.column_weight_unit; p.sync(); var now: u64 = 4 * std.time.ns_per_s; var riding: usize = 0; for (0..40) |_| { now += 16 * std.time.ns_per_ms; p.advance(now); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); p.acknowledgePanelPresentation(s.panelTracks()); const frame = paneFrame(s, p.panes[1].?.serial, p.rects[1]); const box = focusedCursorBox(p, s).?; const want = frame.place(.{ box.x, box.y }); try std.testing.expectApproxEqAbs(want[0], s.chrome.cursor_quad[0], 0.01); try std.testing.expectApproxEqAbs(want[1], s.chrome.cursor_quad[1], 0.01); if (frame.moving()) { riding += 1; // Drawn as the quad while the pane moves. try std.testing.expect(s.chrome.cursor_glide & 1 != 0); } } try std.testing.expect(riding >= 3); // With no transition, the cursor lands with the text at once. p.settings.panel_transition = .off; p.col_weight[0] = layout.column_weight_unit; p.col_weight[1] = layout.column_weight_unit * 2; p.sync(); now += 16 * std.time.ns_per_ms; p.advance(now); _ = arena.reset(.retain_capacity); const landed = try p.render(arena.allocator()); const box = focusedCursorBox(p, landed).?; try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); try std.testing.expect(!p.cursor_glide.moving()); } test "focus moved to another column glides from where the cursor was drawn" { if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.settings.motion = .smooth; try twoColumns(p, &arena); p.active = 0; p.advance(3 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); const before = try p.render(arena.allocator()); try std.testing.expect(!p.cursor_glide.moving()); const was: [2]f32 = .{ before.chrome.cursor_quad[0], before.chrome.cursor_quad[1] }; p.active = 1; _ = arena.reset(.retain_capacity); const start = try p.render(arena.allocator()); // It sets off from where it was drawn, in the left column... try std.testing.expectApproxEqAbs(was[0], start.chrome.cursor_quad[0], 0.01); try std.testing.expectApproxEqAbs(was[1], start.chrome.cursor_quad[1], 0.01); try std.testing.expect(p.cursor_glide.moving() and start.chrome.cursor_glide & 1 != 0); // ...crosses the grid, not squashed against the right one's edge... p.advance(3 * std.time.ns_per_s + 40 * std.time.ns_per_ms); _ = arena.reset(.retain_capacity); const mid = try p.render(arena.allocator()); try std.testing.expect(mid.chrome.cursor_quad[0] > was[0] and mid.chrome.cursor_quad[0] < @as(f32, @floatFromInt(p.rects[1].x)) + 1); // ...and lands on the right one's cursor. p.advance(5 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); const landed = try p.render(arena.allocator()); const box = focusedCursorBox(p, landed).?; try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing); } test "a pane focus reaches brightens on the same spring the one it leaves dims on, and Motion off snaps" { if (!builtins.capabilities.lift) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("some text on a line\n" ** 4); p.newScratchBelow(0); p.settings.inactive_dim = 40; p.settings.motion = .smooth; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ink = struct { // The file's first glyph's ink, in whichever form its body has. fn of(s: *const Surface) [3]u8 { const body = &s.body_layers[0]; const cells = if (body.rows != 0) body.cells[0..@as(usize, body.cols) * body.rows] else s.cells; for (cells) |cell| if (!cell.default and cell.len == 1 and cell.text[0] == 's' and cell.style.font_role == .body) return switch (cell.style.fg) { .rgb => |rgb| rgb, else => .{ 0, 0, 0 }, }; return .{ 0, 0, 0 }; } }.of; p.active = 1; p.advance(std.time.ns_per_s); _ = try p.render(arena.allocator()); p.advance(3 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); const dimmed = ink(try p.render(arena.allocator())); p.active = 0; var now: u64 = 3 * std.time.ns_per_s; var last = dimmed[0]; var steps: usize = 0; for (0..30) |_| { now += 16 * std.time.ns_per_ms; p.advance(now); _ = arena.reset(.retain_capacity); const at = ink(try p.render(arena.allocator()))[0]; // Brighter frame by frame (a flavour may pass it a little), never // the whole way (46 levels here) at once. try std.testing.expect(@abs(@as(i32, at) - @as(i32, last)) <= 12); if (at != last) steps += 1; last = at; } try std.testing.expect(steps >= 4); p.advance(now + 2 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); const bright = ink(try p.render(arena.allocator())); try std.testing.expect(bright[0] > dimmed[0]); // Motion off: both snap. p.settings.motion = .off; p.active = 1; p.advance(now + 3 * std.time.ns_per_s); _ = arena.reset(.retain_capacity); try std.testing.expectEqualSlices(u8, &dimmed, &ink(try p.render(arena.allocator()))); }