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.zig738
1 files changed, 111 insertions, 627 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 42b9c082..a0195fb8 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -1,9 +1,6 @@
-//! 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.
+//! Drawing a pane body into a surface: its text, colours, selections and
+//! cursor, and joining a body with compact context rows through its layer
+//! (Layer.zig).
const std = @import("std");
const pardes = @import("pardes.zig");
const normal = @import("normal.zig");
@@ -29,7 +26,7 @@ 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 TagKind = @import("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;
@@ -40,112 +37,11 @@ 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));
-}
+const Layer = @import("Layer.zig");
// ---- 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 {
+fn paintTerminalSelection(p: *Pardes, s: *Surface, 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;
@@ -178,7 +74,7 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P
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)));
+ const cell = s.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 };
@@ -187,8 +83,8 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P
}
}
-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);
+fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
+ if (pane.isTerminal()) return paintTerminalSelection(p, s, 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;
@@ -209,7 +105,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
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)));
+ const cell = s.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 };
@@ -220,6 +116,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
pub fn paintPointerSelection(
p: *Pardes,
+ s: *Surface,
pane: *Pane,
r: Rect,
tx: u16,
@@ -227,18 +124,19 @@ pub fn paintPointerSelection(
tag_y: u16,
body_y: u16,
sl: Pane.Sel,
+ /// Rows of the selection above this are left alone.
+ from_row: u16,
bg: [3]u8,
fg: ?[3]u8,
) void {
- if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, pane, r, rows, bg, fg);
- const s = &p.surface;
+ if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, s, pane, r, rows, bg, fg);
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;
+ if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) 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.
@@ -255,15 +153,33 @@ pub fn paintPointerSelection(
}
}
-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;
+/// The selection colour of each pointer button's sweep: the select button's
+/// and the look and exec buttons', each tinted toward its own accent.
+pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
+ const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num;
+ return .{
+ 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)),
+ };
+}
+
+/// A pane's body, painted once. A file body with compact tree-sitter
+/// context rows is painted into its body layer -- more rows than the grid
+/// has, the context rows at the tagline's height -- and the grid's rows are
+/// copies of that layer's first ones, at body height. Any other body has no
+/// rows the grid cannot show, and is painted straight onto the grid. The
+/// rail is the grid's alone.
+pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: pardes.Region) !void {
+ const file = if (pane.file) |*f| f else return renderBody(p, s, arena, pane, r, id, active, rail, null);
+ if (!p.settings.tree_context_tag_style or pane.collapsed or r.h <= pane.tag_rows)
+ return renderBody(p, s, arena, pane, r, id, active, rail, null);
+ // Which rows are context rows is decided by the text the body shows.
+ const text = try panes.File.bodyText(arena, pane, file, p.settings.wrap);
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;
+ if (pane.context_rows == 0 or logical_rows <= pane.context_rows)
+ return renderBody(p, s, arena, pane, r, id, active, rail, text);
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);
@@ -272,27 +188,24 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec
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 layer = &s.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);
+ try renderBody(p, &temporary, arena, pane, synthetic, id, active, null, null);
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.kind = .body;
+ layer.id = @intCast(id);
layer.serial = pane.serial;
- layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows };
+ const body = s.region(.body, id).?;
+ layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h };
layer.cols = width;
layer.rows = @intCast(logical_rows);
layer.context_rows = pane.context_rows;
@@ -302,10 +215,29 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec
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 };
}
+ // The grid: the layer's first rows, as many as the body has, each a
+ // body row high; its caret where one of them has it. (pane.rows is the
+ // body's height after sync, and context rows are only prepared while
+ // the rows fit the wrap table, so the layer always has these rows.)
+ std.debug.assert(layer.rows >= viewport_rows);
+ for (0..viewport_rows) |row| {
+ const grid_row = s.cells[(body.rect.y + row) * s.cols ..][body.rect.x..][0..width];
+ @memcpy(grid_row, layer.cells[row * width ..][0..width]);
+ }
+ if (layer.cursor) |cursor| if (cursor.y < viewport_rows) {
+ s.cursor = .{ .x = body.rect.x + cursor.x, .y = body.rect.y + cursor.y, .bar = cursor.bar };
+ };
+ const chrome = p.chromeTheme();
+ const theme = p.theme();
+ const pane_bg: Color = if (theme.bg) |c| .{ .rgb = c } else .default;
+ if (rail.rect.h > 0) {
+ 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 });
+ s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ }
}
-pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void {
- const s = &p.surface;
+pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region, built: ?[]const u8) !void {
const th = p.theme();
const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER;
@@ -325,8 +257,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
.bg = pane_bg,
};
const tz_body = tracy.zone(@src(), "bodyText");
- const body = try bodyText(p, arena, pane);
+ const body = built orelse try bodyText(p, arena, pane);
tz_body.end();
+ // A select sweep begun on the tag and dragged into the body marks the
+ // body rows it crossed only where no text is: it is laid down before
+ // the rows, which paint over it.
+ const sweep = pane.sel[pardes.sel_slot];
+ if (sweep.state != .none and (sweep.state != .done or pardes.sel_slot == 0) and @min(sweep.r0, sweep.r1) < pane.tag_rows)
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sweep, pane.tag_rows, th.sel_bg, th.sel_fg);
{
// The rows themselves. `bodyText` above is only the string BUILD;
// this is what writes it into the surface.
@@ -343,32 +281,25 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
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);
+ panes.File.drawGutter(p, s, 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);
+ panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h);
+ panes.File.drawWrapMarkers(p, s, 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);
+ panes.Terminal.recolorAnsi(p, s, 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)),
- };
+ const sel_btn = buttonTints(th);
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.
+ // win over this quiet affordance.
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)
+ panes.File.paintWordSelection(p, s, 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);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, pane.tag_rows, preview_bg, null);
}
}
const modal_hover = if (p.look_hover_preview) |preview|
@@ -381,7 +312,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
// 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);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, 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) {
@@ -477,6 +408,29 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
}
+ // `gw`'s labels, two letters over the start of each word
+ if (pane.jump.items.len > 0) labels: {
+ const flat = edit.flatSurface(p, &pane.body) catch break :labels;
+ const letters = config.jump_label_alphabet;
+ const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true };
+ for (pane.jump.items, 0..) |word, i| {
+ if (pane.jump_first) |first| if (i / letters.len != first) continue;
+ var at = @min(word.anchor, word.head);
+ for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| {
+ const pos = modal.positionAt(flat, at);
+ const row: i32 = @intCast(pos.row);
+ const wp = pane.wrapRow(row, @intCast(pos.col));
+ const cx = if (pane.file != null)
+ @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col))
+ else
+ panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col);
+ if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw)
+ s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style);
+ at = modal.nextGrapheme(flat, at);
+ }
+ }
+ }
+
// 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)) {
@@ -507,34 +461,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
}
- if (scrollbar and body_h > 0) {
+ if (rail) |bar| if (bar.rect.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 } });
- }
- }
+ s.fill(bar.rect.x, bar.rect.y, 1, bar.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(bar.rect.x + 1, bar.rect.y, 1, bar.rect.h, .{ .bg = pane_bg });
+ s.fill(bar.rect.x, bar.rect.y + bar.thumb_y, 1, bar.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ };
}
pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
@@ -555,453 +489,3 @@ 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;
- 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);
- }
-}