//! 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 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("tag_layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; const BOX_H = pardes.BOX_H; const TAG_GAP = pardes.TAG_GAP; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const TOPBAR_H = pardes.TOPBAR_H; const COLBAR_H = pardes.COLBAR_H; const MAX_PANES = pardes.MAX_PANES; const MAX_COLS = pardes.MAX_COLS; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; /// Where every part of the frame goes, decided once before anything is /// painted: the page; each pane's grip, tag, body, rail (with its thumb) /// and notice chips; each column's grip and tag; the workspace tag. The /// painters below and every shell read these rectangles. pub fn place(p: *Pardes, s: *Surface) void { s.nregions = 0; const add = struct { fn region(surface: *Surface, r: Region) void { std.debug.assert(surface.nregions < surface.regions.len); surface.regions[surface.nregions] = r; surface.nregions += 1; } }.region; add(s, .{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; Messages.collectNotices(p, pane, p.rects[id], id); const r = p.rects[id]; if (r.w <= config.GUTTER or r.h == 0) continue; const owner: u8 = @intCast(id); const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); add(s, .{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; add(s, .{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. if (pane.image == null and body_h > 0) { const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, .offset = page, .len = 1, } else blk: { const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = body_h; const total = if (sb.total == 0) 1 else sb.total; const len = @max(1, (track_h * @max(1, sb.len)) / total); const pos = (track_h * sb.offset) / total; rail.thumb_y = @intCast(@min(pos, track_h)); rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); } add(s, rail); // Each notice a chip at the right edge, one to a row in the order // collectNotices chose, as wide as its own message and no wider, so // the text beneath the rest of the row keeps reading as the file. if (r.h <= pane.tag_rows) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; var leader_buf: [16]u8 = undefined; for (0..pane.notices.len) |i| { const text = Messages.noticeText(p, pane, i, &leader_buf); const chip = Messages.noticeCols(p, text, tw); if (chip == 0) continue; pane.notices.left[i] = tx + tw - chip; add(s, .{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); } } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { if (p.col_w[c] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == c else false; const inset = @min(TAG_TEXT_INSET, p.col_w[c]); add(s, .{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); add(s, .{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); }; if (p.settings.workspace_tag) add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); // A column carried to the window's right edge shows where it lands as a // rail in the last cell: the layers under it stop a cell short. if (p.drag == .column_move) rail: { const d = p.drag.column_move; const source = layout.columnBySerial(p, d.serial) orelse break :rail; const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { .stay => break :rail, .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], .edge => |x| x, }; if (edge != s.cols) break :rail; for (s.regions[0..s.nregions]) |*region| switch (region.kind) { .body, .tag, .notice, .column_tag => if (region.rect.x + region.rect.w == s.cols) { region.clip = 1; }, else => {}, }; } } pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; s.pointer_shape = .arrow; if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { if (preview.probe.link != null and preview.pane < p.panes.len) { if (p.panes[preview.pane]) |pane| { if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) s.pointer_shape = .link; } } }; // A look-hover preview means the pointed word is a real Look or Exec // operand: the same plumbing a click would act on. Hosts that model // the pointer per-text (the macOS I-beam) read this as "target" and // afford it the arrow instead. if (p.look_hover_preview != null) s.pointer_shape = .target; // Stale hover bits must not survive into a frame whose pointer has // moved on: the ordinary content paint does not clear fields it // never set. for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.cols = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); 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 (&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); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } // Each notice once, as a tag layer, and the grid's chip a copy of it. for (s.regionList()) |region| { if (region.kind == .notice) try joinNotice(p, s, arena, region); } 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, "╎"); } if (d.corner) |k| if (d.cur_y < s.rows) { var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; // Where the column's left edge would land, reordered or not. const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { .stay => break :rail, .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], .edge => |x| x, }; // 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(); while (row < s.rows) : (row += 1) { // Past the left edge's scroll rail the rail would cut // through column 0's grip and pane boxes: leave them whole. if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; s.overlayDash(x, row, "╎"); s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; } }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); } }, .none, .select => if (p.pointer_inside) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); } } for (0..p.ncol) |cc| { if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; // the same seam the h-handle hit test picks (see there) if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue; if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } }, } // debug overlay: a small stats box drawn last, top-right ("Debug") if (p.settings.debug) if (p.panes[p.active]) |at| { var nlive: usize = 0; for (p.panes) |slot| { if (slot != null) nlive += 1; } var sb_off: usize = undefined; var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { const sb = panes.Terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } const effective_font = p.settings.font.effective_name.get(); const font_name = if (effective_font.len == 0) "default" else effective_font; var ov_buf: [256]u8 = undefined; const text = std.fmt.bufPrint( &ov_buf, "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, ) catch ""; // wide enough for the longest theme name compiled in, which is the // only row here whose width is not ours to choose const ow: u16 = 38; const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { // vaxis single_rounded border ring, default style s.set(ox, p.topBarHeight(), "╭", .{}); s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); s.set(ox, p.topBarHeight() + oh - 1, "╰", .{}); s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{}); var bx = ox + 1; while (bx < ox + ow - 1) : (bx += 1) { s.set(bx, p.topBarHeight(), "─", .{}); s.set(bx, p.topBarHeight() + oh - 1, "─", .{}); } var by = p.topBarHeight() + 1; while (by < p.topBarHeight() + oh - 1) : (by += 1) { s.set(ox, by, "│", .{}); s.set(ox + ow - 1, by, "│", .{}); } s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); var lines = std.mem.splitScalar(u8, text, '\n'); var ly: u16 = p.topBarHeight() + 1; while (lines.next()) |line| : (ly += 1) { if (ly >= p.topBarHeight() + oh - 1) break; _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); } } }; try p.presentation.submit(p, s); return composeAsciiTransitions(p, arena, s); } pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { if (!track.effect.composedByCore() or track.phase == .closing) continue; const area = animation.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; while (row < row_end) : (row += 1) { var col: u16 = area.x0; while (col < col_end) : (col += 1) { const index = @as(usize, row) * canonical.cols + col; const composed = composedCell(canonical, track, area, col, row, index) orelse continue; if (composed.visuallyEqual(&canonical.cells[index])) continue; if (presented == null) { const next = try arena.create(Surface); next.* = canonical.*; next.cells = try arena.dupe(Cell, canonical.cells); presented = next; } presented.?.cells[index] = composed; } } } return presented orelse canonical; } fn composedCell( canonical: *const Surface, track: animation.Track, area: animation.CellArea, col: u16, row: u16, index: usize, ) ?Cell { if (track.effect == .ascii) { const diff = switch (canonical.cell_diffs[index]) { .ascii => |diff| diff, .unchanged, .visual => return null, }; const byte = diff.byteAt(track.frame); if (byte == diff.to) return null; if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it // borrows whichever side of the diff actually has one. .churn => |byte| blk: { const new_cell = &canonical.cells[index]; if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); const old_cell = &canonical.previous_cells[index]; if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); break :blk old_cell.*; }, .at => |offset| blk: { const source_col = @as(i32, col) + offset.cols; const source_row = @as(i32, row) + offset.rows; // A glyph whose source lies outside the pane has not arrived // yet: the frozen old cell is the only truthful thing under it. if (source_col < area.x0 or source_row < area.y0 or source_col >= @as(i32, area.x0) + area.cols or source_row >= @as(i32, area.y0) + area.rows or source_col >= canonical.cols or source_row >= canonical.rows) break :blk canonical.previous_cells[index]; const source = @as(usize, @intCast(source_row)) * canonical.cols + @as(usize, @intCast(source_col)); break :blk canonical.cells[source]; }, }; } fn withByte(cell: Cell, byte: u8) Cell { var out = cell; out.text[0] = byte; out.len = 1; out.default = false; return out; } /// Whether a cell shows a glyph a character effect can work on. An unpainted /// cell and a space are both blank space, whatever style they carry. fn hasGlyph(cell: *const Cell) bool { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } fn renderPane(p: *Pardes, 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 } }); } const box_bg = if (active) chrome.box else chrome.box_dim; const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); s.set(r.x, tag_y, switch (pane.body.mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, }, box_style); try 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.renderBody(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; for (0..region.rect.h) |line| { const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); } } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { const region = s.region(.column_tag, c) orelse continue; const active = if (layout.findPane(p, p.active)) |found| found.col == c and p.settings.focus_tint else false; const bg = if (active) chrome.tag_active_bg else chrome.tag_bg; const fg = if (active) chrome.tag_active_fg else chrome.tag_fg; if (p.col_x[c] < s.cols) { // A grip is muted until it is grabbed; then it lights, as a // pane's box does for the active pane, so the drag shows what it // holds. const grip = @min(config.GUTTER, p.col_w[c]); const inset = @min(TAG_TEXT_INSET, p.col_w[c]); const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }); s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); } const top = if (p.header_focus and p.header_column == c) p.header_top else 0; for (0..column_rows) |line| { const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; try renderHeaderLayer(p, arena, index, .column, @intCast(c), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, c), line + top), bg, fg); if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); // Laid out at its full width; only shown short of the rail. s.tag_layers[index].viewport.w -= region.clip; } }; } /// Copy a header line's layer into the grid under it, and its caret. fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { const v = layer.viewport; const w = @min(v.w, s.cols -| v.x); joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); if (layer.cursor) |caret| { if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar }; } } /// Copy `w` cells of a layer row, from `from`, into the grid at (x, y) -- /// the one place the grid gets a tag's, a header's or a notice's cells. A /// wide glyph cut by either end is left as the band's blank, the way the /// grid's own print never drew half of one: no head without its spacer, /// no spacer without its head. `ink` is the band's text colour, which a /// blank cell does not carry. fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u16, ink: Color) void { // A tagline wider than the body's cells can lay out fewer than `w`. const n: u16 = @intCast(@min(w, cells.len -| from)); if (n == 0 or y >= s.rows) return; const row = s.cells[@as(usize, y) * s.cols ..][0..s.cols]; @memcpy(row[x..][0..n], cells[from..][0..n]); const blank = struct { fn of(cell: *Cell, band_ink: Color) void { cell.text[0] = ' '; cell.len = 1; if (std.meta.eql(cell.style.fg, band_ink)) cell.style.fg = .default; } }.of; const last = from + n - 1; if (last + 1 < cells.len and !cells[last + 1].default and cells[last + 1].len == 0) blank(&row[x + n - 1], ink); if (!cells[from].default and cells[from].len == 0) blank(&row[x], ink); } fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); while (words.next()) |word| { if (!std.mem.eql(u8, word, "Tty")) continue; const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); return .{ .lo = lo, .hi = lo + word.len }; } return null; } var start: usize = 0; const name = if (pane.file) |file| if (pane.prompt == .name) pane.input.own orelse file.path else file.path else if (pane.image) |state| blk: { // Image tags put renderer controls before the path. start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null; break :blk state.path; } else if (comptime pdf_enabled) blk: { break :blk if (pane.pdf) |state| state.path else return null; } else return null; const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; if (basename == name.len) return null; return .{ .lo = start + basename, .hi = start + name.len }; } /// One row of a header or a notice band as a tag layer: `text` is line /// `line` of what it shows. fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; if (viewport.w == 0) { layer.cols = 0; return; } const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; layer.line = line; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; layer.bg = bg; layer.cursor = null; layer.slide = 0; layer.fade = 1; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select // its text with the column tag's offsets and give it a second caret. const editing: ?*Text = if (p.header_focus and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, .pane, .notice => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); // The word under the pointer: on a header even while it is edited, but // not on a notice (a message is not a command line), nor on a column a // carried column's pointer is aiming at a place over. const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); if (hovered) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) p.hover_col - viewport.x else null; if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; } if (editing) |t| { if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } } const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } } /// A pane's tag rows, painted once into their layers, a layer to a line, /// and copied into the grid; the grid's caret where the layer has it. fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET); // Too narrow for a layer: only the grid's gap cell. if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity); const chrome = p.chromeTheme(); const region = s.region(.tag, id).?; const tag = try tagline.tagText(p, arena, pane); const rows = pane.tag_rows; // As wide as the widest line it shows, and never narrower than its // viewport. var widest: usize = 0; var split = std.mem.splitScalar(u8, tag, '\n'); for (0..pane.tag_top) |_| _ = split.next(); for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(capacity, widest + 1))); const pitch = @as(usize, columns) + TAG_TEXT_INSET; const storage = try arena.alloc(Cell, pitch * rows); @memset(storage, .{}); var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity); const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg }; // A layer to a line: the first in the pane's own slot, the rest in the // slots kept for the lines of taller tags. for (0..rows) |line| { const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); layer.kind = .pane; layer.id = @intCast(id); layer.serial = pane.serial; layer.line = @intCast(line); layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; layer.cols = columns; layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) else null; // The grid's row: the gap cell and the text, as far as the pane. joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg); } if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) { s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar }; }; // A sweep begun on the tag and dragged into the body shows on the body // rows it crossed, which only the grid has. const th = p.theme(); const tx = r.x + TAG_TEXT_INSET; const tw = r.w - TAG_TEXT_INSET; const tag_y = region.rect.y; const body_y = p.bodyTop(pane, r); if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const page_rgb = th.bg orelse th.tag_bg; const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, rows, preview_bg, null); }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, rows, th.sel_bg, th.sel_fg); } } /// A notice chip, painted once as a tag layer at the tagline's pitch, and the /// grid's chip a copy of its right end, where its text is. fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) !void { const chrome = p.chromeTheme(); const th = p.theme(); const id: usize = region.owner; const pane = p.panes[id].?; const i: usize = region.notice; const kind = pane.notices.kinds[i]; const r = p.rects[id]; const chip = region.rect.w; var leader_buf: [16]u8 = undefined; const text = Messages.noticeText(p, pane, i, &leader_buf); // A message coming or going blends toward the page it sits on; a grid // cannot move it by less than a row, a pixel shell slides it too. const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; const page = th.bg orelse chrome.tag_bg; const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha); const fg = Messages.blendRgb(chrome.tag_fg, page, motion.alpha); // Nothing to say, or too narrow for a layer: a blank chip on the grid. if (text.len == 0 or r.w <= TAG_TEXT_INSET) { s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); if (text.len == 0) return; const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); if (kind != .prompt or id != p.active or pane.prompt == .del_side) return; const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); if (prompt_col < printed.dropped) return; s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert }; return; } // Right aligned inside the chip, a blank cell short of its edge: a // prompt's caret sits AFTER its last character the moment it opens, and // flush right would put it past the pane and off the grid. Wider than // the chip, the text loses its head: the end is the part that says // something. const capacity = p.tagCapacity(chip); const room = capacity -| 1; const shown = panes.File.displayWidth(text); var kept = text; if (shown > room) { var start = panes.File.rawAtDisplay(text, shown - room); if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); kept = text[start..]; } const pad = room -| panes.File.displayWidth(kept); const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); const layer = &s.tag_layers[index]; layer.slide = motion.slide; layer.fade = motion.alpha; // The caret rides the band, where the prompt's glyphs are. if (kind == .prompt and id == p.active and pane.prompt != .del_side) caret: { const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) break :caret; layer.cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; } // The grid shows the chip's last cells of the band, where the text ends // (all of it where the tagline's pitch is the grid's). const skip = capacity -| chip; joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg }); // The band's padding before the text is the chip's fill, which carries // no ink of its own (the layer prints it as spaces in the band's ink). for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; if (layer.cursor) |caret| { if (caret.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; } // Laid out at its full width; only shown short of the rail. layer.viewport.w -= region.clip; } pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); const rows = pane.tag_rows; const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); s.fill(tx, tag_y, tw, rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const whole = try tagline.tagText(p, arena, pane); const tail = tagline.curTail(pane); const prefix_len = whole.len - tail.len; // A row per line shown, from `tag_top`: the tag's first line carries // the prefix, the rest are the tag's own text alone. const top = pane.tag_top; var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); var split = std.mem.splitScalar(u8, whole, '\n'); for (0..top) |_| _ = split.next(); for (lines[0..rows]) |*line| line.* = split.next() orelse ""; // The keyboard's caret, as a row and a byte offset in that row's // line: in the tag's own text when the tag has the keyboard, in the // path while a name is drafted. const Caret = struct { row: usize, at: usize }; const caret: ?Caret = if (!active) null else if (pane.prompt == .name) .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { const row: usize = @intCast(pane.tag.cur_row - top); break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; } else null; if (caret) |at| { const line = lines[at.row]; const col = panes.File.rawDisplayCol(line, at.at); var scroll: usize = pane.tag_scroll; if (col < scroll) scroll = col; if (col >= scroll + capacity) scroll = col - capacity + 1; var raw = panes.File.rawAtDisplay(line, scroll); scroll = panes.File.rawDisplayCol(line, raw); if (col >= scroll + capacity) { raw = modal.nextGrapheme(line, raw); scroll = panes.File.rawDisplayCol(line, raw); } pane.tag_scroll = @intCast(scroll); } else if (pane.focus != .tag) pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { const start = panes.File.rawAtDisplay(line, pane.tag_scroll); _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); } const tag = lines[0]; if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); const hi = @min(name.hi, tag.len); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; } }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); body_layer.paintPointerSelection(p, 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(whole, lo); const last = modal.positionAt(whole, hi); var row = @max(first.row, top); while (row <= last.row and row < top + rows) : (row += 1) { const line = lines[row - top]; const from = if (row == first.row) first.col else 0; const to = @min(line.len, if (row == last.row) last.col else line.len); var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(line, to); const y = tag_y + @as(u16, @intCast(row - top)); while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } } // the caret: bar while typing, block in normal mode (same rule as a body) if (caret) |at| { const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); } } test "place puts every part where the painters and layers put it, in every layout" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("placed\n" ** 80); p.newScratchColumn(0); p.newScratchBelow(0); p.sync(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for (0..16) |step| { p.settings.tag_bottom = step & 1 != 0; p.settings.column_tags = step & 2 != 0; if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice")); if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); if (step == 10) { const pane = p.panes[p.active].?; pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird"); } p.sync(); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); try std.testing.expectEqual(Region.Kind.page, s.regions[0].kind); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= config.GUTTER or r.h == 0) continue; const grip = s.region(.grip, id).?; try std.testing.expectEqual(Rect{ .x = r.x, .y = p.tagTop(pane, r), .w = config.GUTTER, .h = pane.tag_rows }, grip.rect); // The grid's grip cell is the grip's. try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role); const tag = s.region(.tag, id).?; try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect); if (r.w > TAG_TEXT_INSET) for (0..pane.tag_rows) |line| { const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }, layer.viewport); }; if (pane.collapsed) { try std.testing.expect(s.region(.body, id) == null); try std.testing.expect(s.region(.rail, id) == null); continue; } const body = s.region(.body, id).?; try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = r.w - config.GUTTER, .h = r.h - pane.tag_rows }, body.rect); const rail = s.region(.rail, id).?; // The painted thumb is exactly the placed one. for (0..rail.rect.h) |row| { const y = rail.rect.y + @as(u16, @intCast(row)); const thumb = row >= rail.thumb_y and row < rail.thumb_y + rail.thumb_h; const want = if (thumb) p.chromeTheme().scroll_thumb else p.chromeTheme().scroll_track; try std.testing.expectEqual(Color{ .rgb = want }, s.at(rail.rect.x, y).style.bg); } var notices: usize = 0; for (s.regionList()) |region| { if (region.kind != .notice or region.owner != id) continue; notices += 1; try std.testing.expectEqual(pane.notices.left[region.notice], region.rect.x); try std.testing.expectEqual(r.x + r.w, region.rect.x + region.rect.w); try std.testing.expectEqual(p.bodyTop(pane, r) + pane.notices.row[region.notice], region.rect.y); try std.testing.expectEqual(region.rect, s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + region.notice].viewport); } try std.testing.expectEqual(@as(usize, pane.notices.len), notices); } for (0..p.ncol) |c| { const region = s.region(.column_tag, c); if (p.columnBarHeight() == 0) { try std.testing.expect(region == null); continue; } try std.testing.expectEqual(Rect{ .x = p.col_x[c] + TAG_TEXT_INSET, .y = p.topBarHeight(), .w = p.col_w[c] - TAG_TEXT_INSET, .h = p.columnBarHeight() }, region.?.rect); } } } 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); } } }