summaryrefslogtreecommitdiff
path: root/src/body_layer.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/body_layer.zig')
-rw-r--r--src/body_layer.zig457
1 files changed, 456 insertions, 1 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 2b2c8a74..ab000795 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -2,7 +2,8 @@
//! 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.
+//! 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");
@@ -24,6 +25,20 @@ 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,
@@ -540,3 +555,443 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
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;
+ 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);
+ _ = 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);
+ 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 tag = tagline.columnTag(p, c);
+ for (0..column_rows) |row| {
+ const line = modal.lineSlice(tag, row);
+ _ = 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);
+ 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);
+ const span = tagline.headerSelection(t, text, row) 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 };
+ }
+ }
+ if (t.cur_row >= 0 and t.cur_row < rows) {
+ const bar = modal.lineSlice(text, @intCast(t.cur_row));
+ 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(t.cur_row)), .bar = t.mode == .insert };
+ }
+ }
+}
+
+fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
+ if (pane.isTerminal()) {
+ var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n");
+ while (words.next()) |word| {
+ if (!std.mem.eql(u8, word, "Tty")) continue;
+ const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
+ return .{ .lo = lo, .hi = lo + word.len };
+ }
+ return null;
+ }
+ var start: usize = 0;
+ const name = if (pane.file) |file|
+ if (pane.prompt == .name) pane.input.own orelse file.path else file.path
+ else if (pane.image) |state| blk: {
+ // Image tags put renderer controls before the path.
+ start = prefix_len -| state.path.len;
+ 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..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;
+ 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), chrome.tag_bg, chrome.tag_fg);
+ }
+ }
+ // Each notice is its own tagline-shaped chip over the top of the body.
+ // Going through the same layer machinery the pane and column tags use
+ // is what gives it the tagline's height, its small-font metrics, the
+ // band offset and the border -- none of which a body-grid row can have
+ // just by wearing a tagline font. The geometry is the one the grid
+ // pass already chose and recorded, so the two cannot disagree and the
+ // GUI's "skip the cells a tag layer covers" leaves no stub behind.
+ // A prompt is one of them too: left on the grid, a pixel shell drew
+ // its tagline glyphs one to a BODY cell, spaced out like a banner.
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const r = p.rects[id];
+ if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue;
+ const first = p.bodyTop(pane, r);
+ var leader_buf: [16]u8 = undefined;
+ for (pane.notices.slice(), 0..) |kind, i| {
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ if (text.len == 0) continue;
+ const cx = pane.notices.left[i];
+ if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
+ const chip = r.x + r.w - cx;
+ // Right aligned inside the chip, a blank cell short of its
+ // edge: the same one the grid pass leaves for a prompt caret.
+ // Wider than the chip, the text loses its head, as on the grid.
+ const room = p.tagCapacity(chip) -| 1;
+ const shown = panes.File.displayWidth(text);
+ var kept = text;
+ if (shown > room) {
+ var start = panes.File.rawAtDisplay(text, shown - room);
+ if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
+ kept = text[start..];
+ }
+ const pad = room -| panes.File.displayWidth(kept);
+ const line = try arena.alloc(u8, pad + kept.len);
+ @memset(line[0..pad], ' ');
+ @memcpy(line[pad..], kept);
+ // A pixel shell slides the message in as it fades up, and
+ // fades it on the way out.
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
+ const fade = motion.alpha;
+ const page = p.theme().bg orelse chrome.tag_bg;
+ const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
+ try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{
+ .x = cx,
+ .y = first + @as(u16, pane.notices.row[i]),
+ .w = chip,
+ .h = BOX_H,
+ }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade));
+ p.surface.tag_layers[layer_index].slide = motion.slide;
+ p.surface.tag_layers[layer_index].fade = fade;
+ // The caret rides the band, where the prompt's glyphs are,
+ // at the same place in the text the grid pass puts it.
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ const dropped = shown - panes.File.displayWidth(kept);
+ if (typed < dropped) continue;
+ p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
+ }
+ }
+ if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
+ if (p.col_w[column] == 0) continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false;
+ 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), 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)) |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;
+ if (t.cur_row == line 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: the first carries the prefix, the rest are the
+ // tag's own text alone.
+ var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
+ var split = std.mem.splitScalar(u8, whole, '\n');
+ 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.tag.cur_row < rows) caret: {
+ const row: usize = @intCast(@max(0, pane.tag.cur_row));
+ const lead: usize = if (row == 0) prefix_len else 0;
+ break :caret .{ .row = row, .at = @min(lines[row].len, lead + @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 (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(tail, 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(tail, lo) >= hi) continue;
+ const first = modal.positionAt(tail, lo);
+ const last = modal.positionAt(tail, hi);
+ var row = first.row;
+ while (row <= last.row and row < rows) : (row += 1) {
+ const line = lines[row];
+ const lead: usize = if (row == 0) prefix_len else 0;
+ const from = lead + if (row == first.row) first.col else 0;
+ const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).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));
+ 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);
+ }
+}