summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/body_layer.zig8
-rw-r--r--src/draw.zig565
-rw-r--r--src/surface.zig4
3 files changed, 271 insertions, 306 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 2b6ae773..e1a45058 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -228,6 +228,8 @@ 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 {
@@ -238,7 +240,7 @@ pub fn paintPointerSelection(
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.
@@ -363,7 +365,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
if (preview.file_word) |word|
panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg)
else if (preview.sel) |sel|
- paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
}
}
const modal_hover = if (p.look_hover_preview) |preview|
@@ -376,7 +378,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
// 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, s, 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, 0, 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) {
diff --git a/src/draw.zig b/src/draw.zig
index e87e0511..b596fc22 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -4,6 +4,7 @@
//! chips, the workspace and column tags, the tag layers, drag overlays, the
//! debug box, then the presentation's tracks and the character effects.
const std = @import("std");
+const vaxis = @import("vaxis");
const pardes = @import("pardes.zig");
const layout = @import("layout.zig");
const animation = @import("animation.zig");
@@ -179,7 +180,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
s.previous_tag_layers = &.{};
s.cell_diffs = &.{};
const chrome = p.chromeTheme();
- const th = p.theme();
place(p, s);
// Separators have their own role; a scrollbar need not frame the page.
const whole = s.regions[0].rect;
@@ -194,69 +194,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active);
try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id);
}
+ // Each notice once, as a tag layer, and the grid's chip a copy of it.
for (s.regionList()) |region| {
- if (region.kind != .notice) continue;
- const id: usize = region.owner;
- const pane = p.panes[id].?;
- const i: usize = region.notice;
- const kind = pane.notices.kinds[i];
- const r = p.rects[id];
- const tx = r.x + config.GUTTER;
- const tw = r.w - config.GUTTER;
- // Painted the way the sticky context headers above them are: the
- // tagline font and the tagline's own colours, so a notice reads as
- // a band belonging to the pane rather than as a line of body text
- // that happens to sit at the bottom of it.
- const msg_style: CellStyle = .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- };
- // An OVERLAY over the body's top rows, at the right edge (placed
- // by `place`) -- not a row taken out of the body the way a
- // tree-sitter context header is.
- var leader_buf: [16]u8 = undefined;
- {
- const row = region.rect.y;
- // Every notice is painted on the canonical grid as well as
- // emitted as a tag layer: the grid is what a terminal client
- // draws, and a band it cannot see is a message it never gets.
- // The GUI skips grid cells a tag layer covers, so nothing is
- // drawn twice there.
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- const chip = region.rect.w;
- const cx = region.rect.x;
- // A message coming or going blends toward the page it sits
- // on; a grid cannot move it by less than a row.
- var style = msg_style;
- if (Messages.noticeLife(pane, i)) |life| {
- // No page colour to fade to (a terminal's own default):
- // the text dissolves into its chip instead.
- const alpha = Messages.messageMotion(p, life).alpha;
- const page = th.bg orelse chrome.tag_bg;
- style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) };
- style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
- }
- s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
- // One column short of the chip's right edge: a prompt's caret
- // sits AFTER its last character the moment it opens, and flush
- // right would put it past the pane, off the grid, and past what
- // the detached wire will encode -- which drops every frame for
- // as long as the prompt is up.
- const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
- if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
- // The cursor follows the text to wherever it landed; a caret
- // in the part a narrow band dropped has nowhere to be.
- const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- if (prompt_col < printed.dropped) continue;
- const caret = printed.left + (prompt_col - printed.dropped);
- if (caret < tx + tw)
- s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
- }
+ if (region.kind == .notice) try joinNotice(p, s, arena, region);
}
- try paintHeaders(p, s);
- try renderTagLayers(p, arena);
+ try paintHeaders(p, s, arena);
switch (p.drag) {
.border_v => |d| {
@@ -526,7 +469,7 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r:
.tty => config.tag_tty,
}, box_style);
- try paintPaneTag(p, s, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET));
+ try joinPaneTag(p, s, arena, pane, r, id, active);
// Folded panes retain their body state, but neither body pixels nor
// its cursor belong outside the one-row tag rectangle.
@@ -554,121 +497,85 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r:
try body_layer.renderBody(p, s, arena, pane, r, id, active, rail.*);
}
-/// 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, s: *Surface) !void {
+/// The workspace and column tags: each line a tag layer, and the grid's
+/// rows copies of those; the column grips, which are the grid's alone.
+pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void {
const chrome = p.chromeTheme();
- 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 };
- }
+ if (s.region(.workspace_tag, Region.none)) |region| {
+ const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
+ for (0..region.rect.h) |line| {
+ const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1;
+ try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg);
+ joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg);
}
}
const column_rows = p.columnBarHeight();
if (column_rows > 0) for (0..p.ncol) |c| {
- const 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,
- });
+ const region = s.region(.column_tag, c) orelse continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == c and p.settings.focus_tint else false;
+ const bg = if (active) chrome.tag_active_bg else chrome.tag_bg;
+ const fg = if (active) chrome.tag_active_fg else chrome.tag_fg;
+ if (p.col_x[c] < s.cols) {
+ // A grip is muted until it is grabbed; then it lights, as a
+ // pane's box does for the active pane, so the drag shows what it
+ // holds.
+ const grip = @min(config.GUTTER, p.col_w[c]);
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
+ const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
+ const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
+ const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
+ s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
}
- // 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 };
- }
- }
+ const top = if (p.header_focus and p.header_column == c) p.header_top else 0;
+ for (0..column_rows) |line| {
+ const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1;
+ try renderHeaderLayer(p, arena, index, .column, @intCast(c), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, c), line + top), bg, fg);
+ if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg);
+ // Laid out at its full width; only shown short of the rail.
+ s.tag_layers[index].viewport.w -= region.clip;
}
};
- 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 };
- }
+}
+
+/// Copy a header line's layer into the grid under it, and its caret.
+fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void {
+ const v = layer.viewport;
+ const w = @min(v.w, s.cols -| v.x);
+ joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg });
+ if (layer.cursor) |caret| {
+ if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar };
}
}
+/// Copy `w` cells of a layer row, from `from`, into the grid at (x, y) --
+/// the one place the grid gets a tag's, a header's or a notice's cells. A
+/// wide glyph cut by either end is left as the band's blank, the way the
+/// grid's own print never drew half of one: no head without its spacer,
+/// no spacer without its head. `ink` is the band's text colour, which a
+/// blank cell does not carry.
+fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u16, ink: Color) void {
+ // A tagline wider than the body's cells can lay out fewer than `w`.
+ const n: u16 = @intCast(@min(w, cells.len -| from));
+ if (n == 0 or y >= s.rows) return;
+ const row = s.cells[@as(usize, y) * s.cols ..][0..s.cols];
+ @memcpy(row[x..][0..n], cells[from..][0..n]);
+ const blank = struct {
+ fn of(cell: *Cell, band_ink: Color) void {
+ cell.text[0] = ' ';
+ cell.len = 1;
+ if (std.meta.eql(cell.style.fg, band_ink)) cell.style.fg = .default;
+ }
+ }.of;
+ const last = from + n - 1;
+ if (last + 1 < cells.len and !cells[last + 1].default and cells[last + 1].len == 0) blank(&row[x + n - 1], ink);
+ if (!cells[from].default and cells[from].len == 0) blank(&row[x], ink);
+}
+
fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
if (pane.isTerminal()) {
var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n");
@@ -694,130 +601,6 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct {
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 region = p.surface.region(.tag, id).?;
- const tag = try tagline.tagText(p, arena, pane);
- const rows = pane.tag_rows;
- // As wide as the widest line it shows, and never narrower than
- // its viewport.
- var widest: usize = 0;
- var split = std.mem.splitScalar(u8, tag, '\n');
- for (0..pane.tag_top) |_| _ = split.next();
- for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
- const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(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 };
- try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET));
- // 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 = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H };
- layer.cols = columns;
- layer.bg = if (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.surface.regionList()) |region| {
- if (region.kind != .notice) continue;
- const id: usize = region.owner;
- const pane = p.panes[id].?;
- const i: usize = region.notice;
- const kind = pane.notices.kinds[i];
- const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET) continue;
- var leader_buf: [16]u8 = undefined;
- {
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- if (text.len == 0) continue;
- const chip = region.rect.w;
- // 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 = region.rect.x,
- .y = region.rect.y,
- .w = chip,
- .h = BOX_H,
- }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade));
- // Laid out at its full width; only shown short of the rail.
- p.surface.tag_layers[layer_index].viewport.w -= region.clip;
- 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;
- const top = if (p.header_focus and p.header_column == column) p.header_top else 0;
- const region = p.surface.region(.column_tag, column) orelse continue;
- 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 = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .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);
- p.surface.tag_layers[index].viewport.w -= region.clip;
- }
- };
-}
-
/// 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 {
@@ -851,19 +634,11 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T
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) {
+ // The word under the pointer: on a header even while it is edited, but
+ // not on a notice (a message is not a command line), nor on a column a
+ // carried column's pointer is aiming at a place over.
+ const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move);
+ if (hovered) {
const tag_hit = p.pointer_tag_hit;
const col: ?usize = if (tag_hit) |pointer|
(if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
@@ -880,6 +655,160 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T
}
};
}
+ if (editing) |t| {
+ if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
+ var col = panes.File.rawDisplayCol(text, span.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(text, span.hi) -| scroll;
+ while (col < end and col < columns) : (col += 1) {
+ surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ }
+ const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll;
+ const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
+ if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert };
+ }
+}
+
+/// A pane's tag rows, painted once into their layers, a layer to a line,
+/// and copied into the grid; the grid's caret where the layer has it.
+fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET);
+ // Too narrow for a layer: only the grid's gap cell.
+ if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity);
+ const chrome = p.chromeTheme();
+ const region = s.region(.tag, id).?;
+ const tag = try tagline.tagText(p, arena, pane);
+ const rows = pane.tag_rows;
+ // As wide as the widest line it shows, and never narrower than its
+ // viewport.
+ var widest: usize = 0;
+ var split = std.mem.splitScalar(u8, tag, '\n');
+ for (0..pane.tag_top) |_| _ = split.next();
+ for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(capacity, widest + 1)));
+ const pitch = @as(usize, columns) + TAG_TEXT_INSET;
+ const storage = try arena.alloc(Cell, pitch * rows);
+ @memset(storage, .{});
+ var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
+ try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity);
+ const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg };
+ // A layer to a line: the first in the pane's own slot, the rest in the
+ // slots kept for the lines of taller tags.
+ for (0..rows) |line| {
+ const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1];
+ if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
+ @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
+ layer.kind = .pane;
+ layer.id = @intCast(id);
+ layer.serial = pane.serial;
+ layer.line = @intCast(line);
+ layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H };
+ layer.cols = columns;
+ layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
+ (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null)
+ else
+ null;
+ // The grid's row: the gap cell and the text, as far as the pane.
+ joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg);
+ }
+ if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) {
+ s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar };
+ };
+ // A sweep begun on the tag and dragged into the body shows on the body
+ // rows it crossed, which only the grid has.
+ const th = p.theme();
+ const tx = r.x + TAG_TEXT_INSET;
+ const tw = r.w - TAG_TEXT_INSET;
+ const tag_y = region.rect.y;
+ const body_y = p.bodyTop(pane, r);
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
+ const page_rgb = th.bg orelse th.tag_bg;
+ const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, rows, preview_bg, null);
+ };
+ }
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (@min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, rows, th.sel_bg, th.sel_fg);
+ }
+}
+
+/// A notice chip, painted once as a tag layer at the tagline's pitch, and the
+/// grid's chip a copy of its right end, where its text is.
+fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) !void {
+ const chrome = p.chromeTheme();
+ const th = p.theme();
+ const id: usize = region.owner;
+ const pane = p.panes[id].?;
+ const i: usize = region.notice;
+ const kind = pane.notices.kinds[i];
+ const r = p.rects[id];
+ const chip = region.rect.w;
+ var leader_buf: [16]u8 = undefined;
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ // A message coming or going blends toward the page it sits on; a grid
+ // cannot move it by less than a row, a pixel shell slides it too.
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
+ const page = th.bg orelse chrome.tag_bg;
+ const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha);
+ const fg = Messages.blendRgb(chrome.tag_fg, page, motion.alpha);
+ // Nothing to say, or too narrow for a layer: a blank chip on the grid.
+ if (text.len == 0 or r.w <= TAG_TEXT_INSET) {
+ s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (text.len == 0) return;
+ const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) return;
+ const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ if (prompt_col < printed.dropped) return;
+ s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert };
+ return;
+ }
+ // Right aligned inside the chip, a blank cell short of its edge: a
+ // prompt's caret sits AFTER its last character the moment it opens, and
+ // flush right would put it past the pane and off the grid. Wider than
+ // the chip, the text loses its head: the end is the part that says
+ // something.
+ const capacity = p.tagCapacity(chip);
+ const room = capacity -| 1;
+ const shown = panes.File.displayWidth(text);
+ var kept = text;
+ if (shown > room) {
+ var start = panes.File.rawAtDisplay(text, shown - room);
+ if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
+ kept = text[start..];
+ }
+ const pad = room -| panes.File.displayWidth(kept);
+ const line = try arena.alloc(u8, pad + kept.len);
+ @memset(line[0..pad], ' ');
+ @memcpy(line[pad..], kept);
+ const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
+ try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg);
+ const layer = &s.tag_layers[index];
+ layer.slide = motion.slide;
+ layer.fade = motion.alpha;
+ // The caret rides the band, where the prompt's glyphs are.
+ if (kind == .prompt and id == p.active and pane.prompt != .del_side) caret: {
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ const dropped = shown - panes.File.displayWidth(kept);
+ if (typed < dropped) break :caret;
+ layer.cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
+ }
+ // The grid shows the chip's last cells of the band, where the text ends
+ // (all of it where the tagline's pitch is the grid's).
+ const skip = capacity -| chip;
+ joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg });
+ // The band's padding before the text is the chip's fill, which carries
+ // no ink of its own (the layer prints it as spaces in the band's ink).
+ for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default;
+ if (layer.cursor) |caret| {
+ if (caret.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar };
+ }
+ // Laid out at its full width; only shown short of the rail.
+ layer.viewport.w -= region.clip;
}
pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
@@ -957,7 +886,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
if (p.look_hover_preview) |preview| {
if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
- body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
};
}
// The tag's own selections, while it has the keyboard, a line at a
@@ -998,7 +927,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
for (pane.sel, 0..) |selection, button| {
if (selection.state == .none or (selection.state == .done and button != 0)) continue;
if (@min(selection.r0, selection.r1) >= rows) continue;
- body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg);
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg);
}
}
@@ -1075,3 +1004,35 @@ test "place puts every part where the painters and layers put it, in every layou
}
}
}
+
+test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 });
+ defer p.deinit();
+ _ = try p.setTestFile("wide\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const pane = p.panes[p.active].?;
+ // Both alignments, so one of them puts a wide glyph across each edge.
+ for ([_][]const u8{ "\u{754c}" ** 30, "x" ++ "\u{754c}" ** 30 }) |wide| {
+ if (pane.tag.own) |own| std.testing.allocator.free(own);
+ pane.tag.own = try std.testing.allocator.dupe(u8, wide);
+ var message: [200]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&message, "Msg {s}", .{wide})));
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ for (s.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .notice) continue;
+ const row = s.cells[@as(usize, region.rect.y) * s.cols ..][region.rect.x..][0..region.rect.w];
+ for (row, 0..) |cell, i| {
+ const spacer = !cell.default and cell.len == 0;
+ const head = !cell.default and cell.len > 1 and i + 1 < row.len and !row[i + 1].default and row[i + 1].len == 0;
+ // A spacer has its head before it; a head its spacer after.
+ if (spacer) try std.testing.expect(i > 0 and row[i - 1].len > 1);
+ if (i + 1 == row.len) try std.testing.expect(!head and !(cell.len > 1 and vaxis.gwidth.gwidth(cell.grapheme(), .unicode) == 2));
+ }
+ // The right edge of each is the band, blank where a glyph was cut.
+ try std.testing.expectEqual(FontRole.tagline, row[row.len - 1].style.font_role);
+ }
+ }
+}
diff --git a/src/surface.zig b/src/surface.zig
index b19c24c7..fec43e30 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -357,7 +357,9 @@ pub const Region = struct {
/// The pane or column has the keyboard's focus.
active: bool = false,
/// A rail's thumb: rows from the rail's top, and how many. Zero rows is
- /// a rail with no thumb (an image).
+ /// a rail with no thumb (an image). A native PDF's thumb is measured in
+ /// document pixels and filled in by Pdf.draw while it paints, so read
+ /// regions once render has returned.
thumb_y: u16 = 0,
thumb_h: u16 = 0,
/// A notice's index in its pane's `notices`.