diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/body_layer.zig | 8 | ||||
| -rw-r--r-- | src/draw.zig | 565 | ||||
| -rw-r--r-- | src/surface.zig | 4 |
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`. |
