//! A pane body as the surface shows it. A file body's logical rows can //! outnumber its physical grid rows: hosts place the compact declaration rows //! first and clip the remaining body rows. After the layer types come the //! functions that draw a body into the surface: its text, colours, selections //! and cursor; then the tags drawn beside it, a pane's and the column and //! workspace ones, each line a tag layer of its own. const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); const edit = @import("edit.zig"); const config = @import("config.zig"); const limits = @import("memory.zig").limits; const layout = @import("layout.zig"); const modal = @import("modal.zig"); const tracy = @import("tracy.zig"); const panes = @import("panes.zig"); const colors = @import("colors.zig"); const Color = @import("surface.zig").Color; const CellStyle = @import("surface.zig").CellStyle; const Cell = @import("surface.zig").Cell; const Surface = @import("surface.zig").Surface; const pdf_enabled = panes.Pdf.enabled; const Pane = panes.Pane; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Rect = layout.Rect; const Pardes = pardes.Pardes; const tagline = @import("tagline.zig"); const Messages = @import("Messages.zig"); const Text = panes.Text; const MAX_PANES = pardes.MAX_PANES; const TagKind = @import("tag_layer.zig").Kind; const 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; const MAX_COLS = pardes.MAX_COLS; const TOPBAR_H = pardes.TOPBAR_H; const COLBAR_H = pardes.COLBAR_H; const TAG_GAP = pardes.TAG_GAP; const wordBoundsAtCol = pardes.wordBoundsAtCol; pub const Metrics = struct { body_w: u16 = 1, body_h: u16 = 1, tagline_w: u16 = 1, tagline_h: u16 = 1, }; pub const Layer = struct { pane: u16 = 0, serial: u32 = 0, viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, cols: u16 = 0, rows: u16 = 0, context_rows: u16 = 0, context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), cells: []pardes.Cell = &.{}, cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, pub fn setContextLines(layer: *Layer, lines: []const i32) void { layer.context_separators = .initEmpty(); const count = @min(lines.len, layer.context_rows); var row: usize = 0; while (row + 1 < count and row < limits.wrap_rows) : (row += 1) { if (lines[row + 1] != lines[row] +| 1) layer.context_separators.set(row); } } pub fn hasContextBorderAfter(layer: *const Layer, row: usize) bool { if (row >= layer.context_rows or row >= limits.wrap_rows) return false; return row + 1 == layer.context_rows or layer.context_separators.isSet(row); } pub fn rowTop(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { const body = @max(1, body_h); const tag = std.math.clamp(tagline_h, 1, body); return @as(f32, @floatFromInt(layer.viewport.y)) * body + @as(f32, @floatFromInt(@min(row, layer.context_rows))) * tag + @as(f32, @floatFromInt(row -| layer.context_rows)) * body; } pub fn rowHeight(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { return if (row < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); } pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; const bw = @max(1, body_w); const bh = @max(1, body_h); const tw = std.math.clamp(tagline_w, 1, bw); const th = std.math.clamp(tagline_h, 1, bh); const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; const row: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); if (row >= layer.rows) return null; const col: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / (if (row < layer.context_rows) tw else bw)))); return .{ .serial = layer.serial, .row = row, .col = col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, }; } }; test "compact body layer rows fill reclaimed height and invert exact boundaries" { const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); for (0..12) |index| { const row: u16 = @intCast(index); const y = layer.rowTop(row, 20, 12) + layer.rowHeight(row, 20, 12) / 2; const width: f32 = if (row < 5) 6 else 10; const hit = layer.hitAt(40 + 3.5 * width, y, 10, 20, 6, 12).?; try std.testing.expectEqual(row, hit.row); try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); } try std.testing.expectEqual(@as(u16, 4), layer.hitAt(45, 119.99, 10, 20, 6, 12).?.row); try std.testing.expectEqual(@as(u16, 5), layer.hitAt(45, 120, 10, 20, 6, 12).?.row); try std.testing.expectEqual(@as(u16, 11), layer.hitAt(45, 259.99, 10, 20, 6, 12).?.row); try std.testing.expect(layer.hitAt(45, 260, 10, 20, 6, 12) == null); try std.testing.expect(layer.hitAt(39.99, 80, 10, 20, 6, 12) == null); try std.testing.expect(layer.hitAt(std.math.nan(f32), 80, 10, 20, 6, 12) == null); } test "compact context borders separate source gaps and preserve multiline headers" { var layer: Layer = .{ .context_rows = 6 }; layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); const expected = [_]bool{ false, true, false, false, true, true, false }; for (expected, 0..) |border, row| try std.testing.expectEqual(border, layer.hasContextBorderAfter(row)); layer.context_rows = 2; layer.setContextLines(&.{ 9, 10 }); try std.testing.expect(!layer.hasContextBorderAfter(0)); try std.testing.expect(layer.hasContextBorderAfter(1)); try std.testing.expect(!layer.hasContextBorderAfter(4)); try std.testing.expect(!layer.context_separators.isSet(4)); } // ---- drawing a pane body into the surface ---- fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { const body = bodyText(p, p.scratch.allocator(), pane) catch return; const raw = pane.body.mode == .tty; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = p.bodyTop(pane, r); var lines = std.mem.splitScalar(u8, body, '\n'); var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { if (visible >= r.h -| pane.tag_rows) break; const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) (if (raw) row.row else pane.surfRow(row.row)) else (if (raw) pane.gridRow(row.row) else row.row); if (target != source_row) continue; var lo = row.lo; var hi = row.hi; if (row.raw_terminal and !raw) { lo -|= row.prompt_bytes; hi -|= row.prompt_bytes; } else if (!row.raw_terminal and raw) { const prefix = panes.Terminal.promptPrefixBytes(pane, source_row, line); lo += prefix; hi += prefix; } lo = modal.graphemeStart(line, @min(lo, line.len)); hi = @min(hi, line.len); if (lo >= hi) continue; var col = panes.File.rawDisplayCol(line, lo); const end = panes.File.rawDisplayCol(line, hi); while (col < end and col < width) : (col += 1) { const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } } fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { if (pane.isTerminal()) return paintTerminalSelection(p, pane, r, rows, bg, fg); const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = p.bodyTop(pane, r); const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var visible: i32 = 0; while (visible < @as(i32, r.h -| pane.tag_rows)) : (visible += 1) { const source = pane.wrapAt(visible); const line = edit.pointerSourceLine(pane, terminal_lines, source.line); const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); const next = pane.wrapAt(visible + 1); const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len; for (rows) |row| { if (row.row != source.line) continue; const lo = @max(start, row.lo); const hi = @min(end, row.hi); if (lo >= hi) continue; var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start))); const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start))); while (col < last and col < width) : (col += 1) { const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } } pub fn paintPointerSelection( p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16, tag_y: u16, body_y: u16, sl: Pane.Sel, bg: [3]u8, fg: ?[3]u8, ) void { if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, pane, r, rows, bg, fg); const s = &p.surface; const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. const scroll: i32 = if (row < pane.tag_rows) pane.tag_scroll else 0; var col: i32 = if (pane.file != null and row >= pane.tag_rows) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); const row_x = if (row < pane.tag_rows) r.x + @min(TAG_TEXT_INSET, r.w) else tx; const row_width = if (row < pane.tag_rows) r.w -| TAG_TEXT_INSET else tw; while (col <= c1 - scroll and col < row_width) : (col += 1) { const cell = s.at(row_x + @as(u16, @intCast(col)), sy); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return; const viewport_rows = r.h - pane.tag_rows; if (viewport_rows == 0) return; // The canonical fixed grid remains available to terminal clients. The // separate body layer reserves enough rows for any compact font size. const logical_rows = @min(@as(usize, pane.rows) + pane.context_rows, pane.wrap_line.len); if (logical_rows <= pane.context_rows) return; pane.body_rows = @intCast(logical_rows); const bh: usize = @max(1, p.row_metrics.body_h); const th: usize = std.math.clamp(p.row_metrics.tagline_h, 1, bh); const body_pixels = @as(usize, viewport_rows) * bh; pane.context_row_limit = @intCast(@min(pane.context_rows, (body_pixels -| bh) / th)); const context_pixels = @as(usize, pane.context_row_limit.?) * th; pane.body_visible_rows = @intCast(@min(logical_rows, pane.context_row_limit.? + @max(1, (body_pixels -| context_pixels) / bh))); const width = r.w - config.GUTTER; const layer = &p.surface.body_layers[id]; const count = logical_rows * width; if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows)); @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; std.mem.swap(Surface, &p.surface, &temporary); const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; renderBody(p, arena, pane, synthetic, id, id == p.active, false) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } layer.pane = @intCast(id); layer.serial = pane.serial; layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; layer.setContextLines(pane.wrap_line[0..pane.context_rows]); layer.cursor = null; if (temporary.cursor) |cursor| { if (cursor.x >= config.GUTTER and cursor.y >= first_row) layer.cursor = .{ .x = cursor.x - config.GUTTER, .y = @intCast(cursor.y - first_row), .bar = cursor.bar }; } } pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const tag_y = p.tagTop(pane, r); // Below the notice bands: they are stacked at the top of the body the // way tree-sitter context rows are, so the body starts under them // rather than being painted over by them. const body_y = p.bodyTop(pane, r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; const page_rgb = th.bg orelse th.tag_bg; // body: emulator rows, prompt rows hidden outside tty mode const body_h = r.h -| pane.tag_rows; const body_bottom = r.h; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = pane_bg, }; const tz_body = tracy.zone(@src(), "bodyText"); const body = try bodyText(p, arena, pane); tz_body.end(); { // The rows themselves. `bodyText` above is only the string BUILD; // this is what writes it into the surface. const tz_rows = tracy.zone(@src(), "paneBodyRows"); defer tz_rows.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { if (i >= body_h) break; _ = s.print(tx, body_y + i, tw, line, body_style); } } const tz_color = tracy.zone(@src(), "paneRecolor"); // File layout is independent of whether its producer requests colors. if (pane.file) |*f| { panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); if (p.settings.colors and pane.colorAlgo() != .none) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body); } tz_color.end(); const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)), colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)), }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below // win over this quiet affordance. mark_hover carries the // cells this paints out to the hosts as the affordance rect — // macOS composes its glass overlay over exactly these. const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| preview.pane == id and preview.serial == pane.serial and preview.sel == null and preview.file_word == null else false; for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { const hover_only = modal_hover and pane.body.mode != .normal; const modal_bg = if (hover_only) colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)) else th.sel_bg; const lo = @min(pane.body.msel.r0, pane.body.msel.r1); const hi = @max(pane.body.msel.r0, pane.body.msel.r1); var row: u16 = pane.tag_rows; // never paint the tag row while (row < body_bottom) : (row += 1) { const ar = pane.wrapAt(@as(i32, row) - @as(i32, pane.tag_rows)).line; if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - pane.tag_rows); cell.default = false; cell.style.bg = .{ .rgb = modal_bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } } const preview = normal.selRegexArmed(pane) != null; const show_prim = (pane.body.mode == .normal and pane.body.vsel.active) or preview or (modal_hover and pane.body.vsel.active); const show_extra = pane.body.mode != .tty and pane.body.nsel > 0; if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var si: usize = 0; while (si <= pane.body.nsel) : (si += 1) { const primary = si == pane.body.nsel; if (if (primary) !show_prim else !show_extra) continue; const sr = if (primary) panes.Text.SelRange{ .row = pane.body.cur_row, .col = pane.body.cur_col, .arow = if (pane.body.vsel.active) pane.body.vsel.row else pane.body.cur_row, .acol = if (pane.body.vsel.active) pane.body.vsel.col else pane.body.cur_col, } else pane.body.sels[si]; const bnd = edit.cellBounds(sr); const hover_only = primary and modal_hover and pane.body.mode != .normal and !preview; const bg = if (hover_only) colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else colors.mix(th.sel_bg, page_rgb); var vr: i32 = 0; while (vr + @as(i32, pane.tag_rows) < @as(i32, body_bottom)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); const cstart: i32 = if (w.line == bnd.lo_row) (if (pane.file != null) panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col) else panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; const cend: i32 = if (w.line == bnd.hi_row) (if (pane.file != null) panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else panes.File.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's if (hover_only) continue; // quiet preview preserves the source ink const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, pane.tag_rows); const ccol = (if (pane.file != null) panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), )) + vpfx; if (crow >= pane.tag_rows and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - pane.tag_rows))); cell.default = false; cell.style.bg = .{ .rgb = th.sel_fg }; cell.style.fg = .{ .rgb = th.sel_bg }; } } } // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { if (pane.body.mode != .tty) { const cur = panes.Terminal.gridCursor(pane); const goff = panes.Terminal.gridOffset(pane); const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, pane.tag_rows); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, crow, cwp.at, ccol) else if (pane.body.cur_pinned) panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), ) else ccol; if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert }; } else if (panes.Terminal.visibleCursor(pane)) |cur| { if (cur.y + pane.tag_rows < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } if (scrollbar and body_h > 0) { // The rail covers the notice bands too: nothing else paints those // two gutter columns, so a gap there keeps the frame-wide border // fill and reads as a notch cut out of the scrollbar. const rail_h = r.h -| pane.tag_rows; s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, .offset = page, .len = 1, } else blk: { const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = body_h; const total = if (sb.total == 0) 1 else sb.total; const len = @max(1, (track_h * @max(1, sb.len)) / total); const pos = (track_h * sb.offset) / total; var sy: usize = pos; while (sy < track_h and sy < pos + len) : (sy += 1) { s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); } } } pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } if (pane.hasPdf()) { if (comptime pdf_enabled) return panes.Pdf.visibleText( &pane.pdf.?, p.pdf_gpa, arena, pane.rows -| BOX_H, ); unreachable; } if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); return panes.Terminal.bodyText(arena, pane); } /// The workspace and column tags as the grid shows them: their text, the /// word under the pointer and, in the one being edited, its selection and /// caret. Their tag layers are drawn by renderTagLayers. pub fn paintHeaders(p: *Pardes) !void { const s = &p.surface; const chrome = p.chromeTheme(); const th = p.theme(); // global tagbar: full width, top rows — unless a native shell has // taken its commands over, in which case the rows are not ours to draw tagline.revealHeader(p); const global = tagline.topbar(p); const global_rows = p.topBarHeight(); const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; if (p.settings.workspace_tag) { s.fill(0, 0, s.cols, global_rows, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); for (0..global_rows) |row| { const line = modal.lineSlice(global, row + global_top); _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); } } if (p.pointer_inside and p.hover_row < global_rows) { const bar = modal.lineSlice(global, p.hover_row + global_top); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), p.hover_row); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { const grip = @min(config.GUTTER, p.col_w[c]); const inset = @min(TAG_TEXT_INSET, p.col_w[c]); const x = p.col_x[c] + inset; const w = p.col_w[c] - inset; const y0 = global_rows; if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; // A grip is muted until it is grabbed; then it lights, as a pane's // box does for the active pane, so the drag shows what it holds. const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; s.fill(p.col_x[c], y0, grip, column_rows, grip_style); s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; const tag = tagline.columnTag(p, c); for (0..column_rows) |row| { const line = modal.lineSlice(tag, row + bar_top); _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline, }); } // A carried column's pointer is aiming at a place, not a word. if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } }; if (tagline.focusedHeader(p)) |t| { const text = try edit.flatSurface(p, t); const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; const y: u16 = if (p.header_column != null) global_rows else 0; const rows = if (p.header_column != null) column_rows else global_rows; for (0..rows) |row| { const bar = modal.lineSlice(text, row + p.header_top); const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue; var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); cell.style.fg = .{ .rgb = th.sel_fg }; cell.style.bg = .{ .rgb = th.sel_bg }; } } // A prompt answering for the header has the caret on its band. const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; const cur: usize = @intCast(@max(0, t.cur_row)); if (!asking and cur >= p.header_top and cur < p.header_top + rows) { const bar = modal.lineSlice(text, cur); const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; } } } fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { // `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 }; } pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const chrome = p.chromeTheme(); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; const tag = try tagline.tagText(p, arena, pane); const rows = pane.tag_rows; // As wide as the widest line it shows, and never narrower than // its viewport. var widest: usize = 0; var split = std.mem.splitScalar(u8, tag, '\n'); for (0..pane.tag_top) |_| _ = split.next(); for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); const pitch = @as(usize, columns) + TAG_TEXT_INSET; const storage = try arena.alloc(Cell, pitch * rows); @memset(storage, .{}); var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; std.mem.swap(Surface, &p.surface, &temporary); paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); // A layer to a line: the first in the pane's own slot, the rest // in the slots kept for the lines of taller tags. for (0..rows) |line| { const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); layer.kind = .pane; layer.id = @intCast(id); layer.serial = pane.serial; layer.line = @intCast(line); layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; layer.cols = columns; layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) else null; } } // With the workspace tag row handed to native chrome there is no // workspace layer to render; the menu bar carries its commands. if (p.settings.workspace_tag) { for (0..p.topBarHeight()) |line| { const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; const top = if (p.header_focus and p.header_column == null) p.header_top else 0; try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); } } // Each notice is its own tagline-shaped chip over the top of the body. // Going through the same layer machinery the pane and column tags use // is what gives it the tagline's height, its small-font metrics, the // band offset and the border -- none of which a body-grid row can have // just by wearing a tagline font. The geometry is the one the grid // pass already chose and recorded, so the two cannot disagree and the // GUI's "skip the cells a tag layer covers" leaves no stub behind. // A prompt is one of them too: left on the grid, a pixel shell drew // its tagline glyphs one to a BODY cell, spaced out like a banner. for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; const first = p.bodyTop(pane, r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const text = Messages.noticeText(p, pane, i, &leader_buf); if (text.len == 0) continue; const cx = pane.notices.left[i]; if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; const chip = r.x + r.w - cx; // Right aligned inside the chip, a blank cell short of its // edge: the same one the grid pass leaves for a prompt caret. // Wider than the chip, the text loses its head, as on the grid. const room = p.tagCapacity(chip) -| 1; const shown = panes.File.displayWidth(text); var kept = text; if (shown > room) { var start = panes.File.rawAtDisplay(text, shown - room); if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); kept = text[start..]; } const pad = room -| panes.File.displayWidth(kept); const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); // A pixel shell slides the message in as it fades up, and // fades it on the way out. const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; const fade = motion.alpha; const page = p.theme().bg orelse chrome.tag_bg; const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ .x = cx, .y = first + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H, }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg, page, fade)); p.surface.tag_layers[layer_index].slide = motion.slide; p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, // at the same place in the text the grid pass puts it. if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue; const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) continue; p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { if (p.col_w[column] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; const top = if (p.header_focus and p.header_column == column) p.header_top else 0; for (0..p.columnBarHeight()) |line| { const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); } }; } /// One row of a header or a notice band as a tag layer: `text` is line /// `line` of what it shows. fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; if (viewport.w == 0) { layer.cols = 0; return; } const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; layer.line = line; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; layer.bg = bg; layer.cursor = null; layer.slide = 0; layer.fade = 1; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select // its text with the column tag's offsets and give it a second caret. const editing: ?*Text = if (p.header_focus and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, .pane, .notice => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); if (editing) |t| { if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } } const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } else if (p.pointer_inside) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) p.hover_col - viewport.x else null; if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; } } pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); const rows = pane.tag_rows; const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); s.fill(tx, tag_y, tw, rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const whole = try tagline.tagText(p, arena, pane); const tail = tagline.curTail(pane); const prefix_len = whole.len - tail.len; // A row per line shown, from `tag_top`: the tag's first line carries // the prefix, the rest are the tag's own text alone. const top = pane.tag_top; var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); var split = std.mem.splitScalar(u8, whole, '\n'); for (0..top) |_| _ = split.next(); for (lines[0..rows]) |*line| line.* = split.next() orelse ""; // The keyboard's caret, as a row and a byte offset in that row's // line: in the tag's own text when the tag has the keyboard, in the // path while a name is drafted. const Caret = struct { row: usize, at: usize }; const caret: ?Caret = if (!active) null else if (pane.prompt == .name) .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { const row: usize = @intCast(pane.tag.cur_row - top); break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; } else null; if (caret) |at| { const line = lines[at.row]; const col = panes.File.rawDisplayCol(line, at.at); var scroll: usize = pane.tag_scroll; if (col < scroll) scroll = col; if (col >= scroll + capacity) scroll = col - capacity + 1; var raw = panes.File.rawAtDisplay(line, scroll); scroll = panes.File.rawDisplayCol(line, raw); if (col >= scroll + capacity) { raw = modal.nextGrapheme(line, raw); scroll = panes.File.rawDisplayCol(line, raw); } pane.tag_scroll = @intCast(scroll); } else if (pane.focus != .tag) pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { const start = panes.File.rawAtDisplay(line, pane.tag_scroll); _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); } const tag = lines[0]; if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); const hi = @min(name.hi, tag.len); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; } }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } // The tag's own selections, while it has the keyboard, a line at a // time. A range of one grapheme is the block cursor, not a selection. if (pane.focus == .tag) { var ranges: [Text.max_selections]modal.Selection = undefined; const got = pane.tag.ranges(whole, 0, &ranges); for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (modal.nextGrapheme(whole, lo) >= hi) continue; const first = modal.positionAt(whole, lo); const last = modal.positionAt(whole, hi); var row = @max(first.row, top); while (row <= last.row and row < top + rows) : (row += 1) { const line = lines[row - top]; const from = if (row == first.row) first.col else 0; const to = @min(line.len, if (row == last.row) last.col else line.len); var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(line, to); const y = tag_y + @as(u16, @intCast(row - top)); while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } } // the caret: bar while typing, block in normal mode (same rule as a body) if (caret) |at| { const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } }