diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 328 |
1 files changed, 280 insertions, 48 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index ec743bc6..c271a16f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -75,7 +75,9 @@ pub const RowMetrics = @import("body_layer.zig").Metrics; pub const TagLayer = @import("tag_layer.zig").Layer; pub const TagHit = @import("tag_layer.zig").Hit; pub const TagKind = @import("tag_layer.zig").Kind; -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1; +/// Panes, then columns, then the workspace bar, then each pane's notice bands. +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; +pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -1168,7 +1170,17 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; - try std.testing.expectEqualStrings("!", p.surface.at(r.x + config.GUTTER, r.y + r.h - 1).grapheme()); + // The message is a tagline band at the top of the body now, not a body row + // at the bottom: same reservation, different carrier. + const band = for (p.surface.tagLayers()) |*layer| { + if (layer.cols != 0 and layer.kind == .notice) break layer; + } else return error.NoNoticeBand; + try std.testing.expectEqual(r.y + BOX_H, band.viewport.y); + // Right aligned: the text sits in the last cell the viewport actually + // shows, which is a column short of the layer's cursor slack. + try std.testing.expectEqualStrings("!", band.cells[p.tagCapacity(band.viewport.w) - 1].grapheme()); + // ...and the body starts below it rather than under it. + try std.testing.expectEqual(band.viewport.y + 1, p.surface.body_layers[0].viewport.y); pane.msg_len = 0; p.leader_on = true; _ = try p.render(p.scratch.allocator()); @@ -1178,7 +1190,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows); try std.testing.expectEqual(@as(u16, 0), pane.body_rows); - try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, r.y + BOX_H).style.font_role); + try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(pane, r)).style.font_role); try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); @@ -6270,6 +6282,15 @@ pub const Pardes = struct { return p; } + /// Prints `text` flush with the right edge of the band, and answers the + /// column it started at so a cursor can follow it. + fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) u16 { + const shown: u16 = @intCast(@min(@as(usize, w), panes.File.displayWidth(text))); + const left = x + w - shown; + _ = s.print(left, row, shown, text, style); + return left; + } + /// The layout every single-pane boot starts from. fn oneColumn(p: *Pardes) void { p.ncol = 1; @@ -6915,7 +6936,15 @@ pub const Pardes = struct { false; } + /// Something changed the core without going through `update` -- a watched + /// file reloading under it, a theme file rewritten on disk. `pump` draws + /// only when it has a reason to, and this is the reason. + pub fn invalidate(p: *Pardes) void { + p.needs_frame = true; + } + pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { + p.invalidate(); p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); @@ -11235,9 +11264,13 @@ pub const Pardes = struct { const r = p.rects[id]; if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(p.panes[id].?, r); const on_tag = row >= tag_y and row < tag_y + BOX_H; if (on_tag and col < r.x + TAG_TEXT_INSET) return null; + // A notice band is chrome. Without this it maps to Sel row 0 or less, + // which is the TAG row -- so a click on a leader band would expand a + // word out of the tag text and run it as a builtin. + if (!on_tag and row >= body_y -| p.panes[id].?.body_offset and row < body_y) return null; if (!on_tag) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null; const c = @as(i32, mapped_hit.col) - config.GUTTER; @@ -11624,7 +11657,7 @@ pub const Pardes = struct { if (previous.tag_edit) exitTagEdit(previous); }; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); if (mcol < r.x + config.GUTTER) { // gutter scrollbar: a left click scrolls UP to that row p.active = id; @@ -11688,7 +11721,7 @@ pub const Pardes = struct { // the tag row and the body's first, exactly as the select // arm above derives them const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { const local = @as(i32, mrow) - @as(i32, body_y); @@ -11780,13 +11813,17 @@ pub const Pardes = struct { return; }; pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); - if (p.settings.tag_bottom) { - const body_h = r.h -| BOX_H; - pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) - 0 - else - @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); - } else pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H) 0 else @as(i32, mrow) - @as(i32, r.y); + // Sel row BOX_H is the body's FIRST row, the same thing + // pointerTextSelection means by it -- so the drag has to + // measure from the body's top, notice bands included, or a + // selection made while a message is up lands rows away + // from the pointer. + const body_y = p.bodyTop(pane, r); + const body_h = (r.h -| BOX_H) -| pane.body_offset; + pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) + 0 + else + @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + @as(i32, BOX_H); if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); @@ -13333,6 +13370,14 @@ pub const Pardes = struct { return null; } + /// The pane's first body row: under the tag when it is on top, and under + /// whatever notice bands `collectNotices` stacked above the body. Every + /// painter and hit test derives the body from here so the bands cannot + /// come adrift from the text they sit over. + pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { + return (if (p.settings.tag_bottom) r.y else r.y + BOX_H) + pane.body_offset; + } + fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); @@ -13416,37 +13461,49 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }; - // The stack sits directly above the tagline, one line to a row, in - // the order collectNotices chose. A single notice therefore lands - // on exactly the row the message used to have to itself. - const bottom = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; + // Sticky at the TOP of the body, where the tree-sitter context + // headers sit, one line to a row in the order collectNotices chose. + // Text is right aligned: the left edge is where the body's own text + // begins, and a notice that shared it would read as a line of the + // file rather than as chrome over it. + const first = if (p.settings.tag_bottom) r.y else r.y + BOX_H; for (pane.notices.slice(), 0..) |kind, i| { - const row = bottom - @as(u16, @intCast(pane.notices.len - 1 - i)); + const row = first + @as(u16, @intCast(i)); + // 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. s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg, .font_role = .tagline }); switch (kind) { - .message => _ = s.print(tx, row, tw, pane.msg[0..pane.msg_len], msg_style), + .message => _ = printRight(s, tx, row, tw, pane.msg[0..pane.msg_len], msg_style), .leader => { var ibuf: [16]u8 = @splat(' '); - @memcpy(ibuf[1..4], "SPC"); - var iw: usize = 4; + @memcpy(ibuf[0..3], "SPC"); + var iw: usize = 3; for (p.leader_keys[0..p.leader_n]) |ch| { ibuf[iw + 1] = ch; iw += 2; } - const w: u16 = @intCast(iw); - if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); + _ = printRight(s, tx, row, tw, ibuf[0..iw], msg_style); }, .prompt => { const at = pane.promptAt() orelse continue; const text = pane.tagSlice()[@min(at, pane.tag_tail_len)..]; - _ = s.print(tx, row, tw, text, msg_style); + // One column short of the edge: the caret sits AFTER + // the last character the moment a prompt opens, and + // flush right would put it at r.x + r.w -- off the + // pane, off the grid, and past what the detached wire + // will encode, which drops every frame while it is up. + const left = printRight(s, tx, row, tw -| 1, text, msg_style); if (id != p.active) continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col >= prompt0) { + // The cursor follows the text to wherever it landed. const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < tw) - s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; + s.cursor = .{ .x = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } }, } @@ -13772,11 +13829,11 @@ pub const Pardes = struct { const raw = pane.mode == .tty; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); var lines = std.mem.splitScalar(u8, body, '\n'); var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { - if (visible >= r.h -| BOX_H) break; + if (visible >= (r.h -| BOX_H) -| pane.body_offset) break; const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) @@ -13814,10 +13871,10 @@ pub const Pardes = struct { const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var visible: i32 = 0; - while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) { + while (visible < @as(i32, (r.h -| BOX_H) -| pane.body_offset)) : (visible += 1) { const source = pane.wrapAt(visible); const line = pointerSourceLine(pane, terminal_lines, source.line); const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); @@ -13862,6 +13919,11 @@ pub const Pardes = struct { while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; + // The body sits `body_offset` rows lower than it used to, so the + // last rows of Sel space now fall past the pane. Bounding the loop + // instead would break the caller that paints a tag into a + // one-row surface of its own, where the offset means nothing. + if (row >= BOX_H and sy >= r.y + r.h) break; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; @@ -13910,6 +13972,50 @@ pub const Pardes = struct { // workspace layer to render; the menu bar carries its commands. if (p.settings.workspace_tag) try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); + // Each notice is its own tagline-shaped band at 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. + 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 <= BOX_H) continue; + const first = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + var band: u16 = 0; + for (pane.notices.slice()) |kind| { + const text = switch (kind) { + .message => pane.msg[0..pane.msg_len], + .leader => blk: { + var ibuf: [16]u8 = @splat(' '); + @memcpy(ibuf[0..3], "SPC"); + var iw: usize = 3; + for (p.leader_keys[0..p.leader_n]) |ch| { + ibuf[iw + 1] = ch; + iw += 2; + } + break :blk try arena.dupe(u8, ibuf[0..iw]); + }, + // A prompt is an editing line with a cursor in it, so it + // stays on the grid where the cursor can sit on a cell. + .prompt => continue, + }; + if (text.len == 0) continue; + const room = p.tagCapacity(r.w -| TAG_TEXT_INSET); + const shown = panes.File.displayWidth(text); + const pad = room -| shown; + const line = try arena.alloc(u8, pad + text.len); + @memset(line[0..pad], ' '); + @memcpy(line[pad..], text); + try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + band, .notice, @intCast(id), .{ + .x = r.x + TAG_TEXT_INSET, + .y = first + band, + .w = r.w -| TAG_TEXT_INSET, + .h = BOX_H, + }, line, chrome.tag_bg, chrome.tag_fg); + band += 1; + } + } 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; @@ -13934,7 +14040,14 @@ pub const Pardes = struct { layer.bg = bg; layer.cursor = null; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; - const editing = p.topbar_col != null and (if (kind == .workspace) p.header_column == null else p.header_column == id); + // 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 = p.topbar_col != null and switch (kind) { + .workspace => p.header_column == null, + .column => p.header_column == id, + .pane, .notice => false, + }; const scroll = if (editing) 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 }); @@ -13974,6 +14087,7 @@ pub const Pardes = struct { /// them instead of letting the last one win the row. fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void { pane.notices.len = 0; + pane.body_offset = 0; if (r.w <= config.GUTTER or r.h <= BOX_H) return; if (pane.msg_len > 0) pane.notices.push(.message); if (id == p.active and p.leader_on) pane.notices.push(.leader); @@ -13991,6 +14105,7 @@ pub const Pardes = struct { for (0..room) |i| pane.notices.kinds[i] = pane.notices.kinds[i + drop]; pane.notices.len = room; } + pane.body_offset = pane.notices.len; } fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { @@ -14017,6 +14132,9 @@ pub const Pardes = struct { @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells }; std.mem.swap(Surface, &p.surface, &temporary); + // This surface carries no notice bands, so the body starts at its top. + pane.body_offset = 0; + defer pane.body_offset = pane.notices.len; const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) }; p.renderBody(arena, pane, synthetic, id, id == p.active, false) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); @@ -14030,7 +14148,11 @@ pub const Pardes = struct { } layer.pane = @intCast(id); layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y else r.y + BOX_H, .w = width, .h = viewport_rows }; + // Below the notice bands, which are stacked at the TOP of the body + // now: the rows they took came off this viewport, so its origin has to + // move with them or the first context header renders under a band. + const body_top = (if (p.settings.tag_bottom) r.y else r.y + BOX_H) + @as(u16, pane.notices.len); + layer.viewport = .{ .x = r.x + config.GUTTER, .y = body_top, .w = width, .h = viewport_rows }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; @@ -14052,7 +14174,7 @@ pub const Pardes = struct { const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); @@ -14092,11 +14214,14 @@ pub const Pardes = struct { if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { - panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); + const image_h = (r.h -| BOX_H) -| pane.body_offset; + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, image_h); // thumbless, but the same one column as the real scrollbar below — - // that is the whole point of drawing it - s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + // that is the whole point of drawing it, and like that one it runs + // past the notice bands so the gutter has no notch in it + const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + s.fill(r.x, rail_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, rail_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } @@ -14110,7 +14235,7 @@ pub const Pardes = struct { const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const body_y = p.bodyTop(pane, r); 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; @@ -14194,11 +14319,15 @@ pub const Pardes = struct { const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + // Below the notice bands: they are stacked at the top of the body the + // way tree-sitter context rows are, so the body starts under them + // rather than being painted over by them. + const body_y = p.bodyTop(pane, r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; const page_rgb = th.bg orelse th.tag_bg; // body: emulator rows, prompt rows hidden outside tty mode - const body_h = if (r.h > BOX_H) r.h - BOX_H else 0; + const body_h = (r.h -| BOX_H) -| pane.body_offset; + const body_bottom = r.h -| pane.body_offset; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = pane_bg, @@ -14269,7 +14398,7 @@ pub const Pardes = struct { const lo = @min(pane.msel.r0, pane.msel.r1); const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row - while (row < r.h) : (row += 1) { + while (row < body_bottom) : (row += 1) { const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; @@ -14306,7 +14435,7 @@ pub const Pardes = struct { else mix(th.sel_bg, page_rgb); var vr: i32 = 0; - while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { + while (vr + @as(i32, BOX_H) < @as(i32, body_bottom)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); @@ -14344,7 +14473,7 @@ pub const Pardes = struct { @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), )) + vpfx; - if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { + if (crow >= BOX_H and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H))); cell.default = false; cell.style.bg = .{ .rgb = th.sel_fg }; @@ -14375,17 +14504,22 @@ pub const Pardes = struct { ) else ccol; - if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) + if (prow >= BOX_H and cx >= 0 and prow < body_bottom and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert }; } else if (panes.Terminal.visibleCursor(pane)) |cur| { - if (cur.y + BOX_H < r.h and cur.x < tw) + if (cur.y + BOX_H < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } - if (scrollbar and r.h > BOX_H) { - s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); + if (scrollbar and body_h > 0) { + // The rail covers the notice bands too: nothing else paints those + // two gutter columns, so a gap there keeps the frame-wide border + // fill and reads as a notch cut out of the scrollbar. + const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const rail_h = r.h -| BOX_H; + s.fill(r.x, rail_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, rail_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, @@ -14398,7 +14532,7 @@ pub const Pardes = struct { const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; - const track_h: usize = r.h - BOX_H; + 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; @@ -14658,6 +14792,24 @@ test "a builtin announces itself on the message row, and Verbose silences it" { try std.testing.expect(pane.notices.len > 0); try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[0]); + // ...and it is carried as a tagline band, not a body row: that is what + // gives it the tagline's height, its small font and its border. + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const r = p.rects[p.active]; + const band = for (p.surface.tagLayers()) |*layer| { + if (layer.cols != 0 and layer.kind == .notice) break layer; + } else return error.NoNoticeBand; + try std.testing.expectEqual(@as(u16, 1), band.viewport.h); + try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y); + // Right aligned: the text ends at the band's right edge. + const line = band.cells[0..band.cols]; + var last: usize = line.len; + while (last > 0 and line[last - 1].grapheme()[0] == ' ') last -= 1; + try std.testing.expect(last > 0); + try std.testing.expect(line[0].grapheme()[0] == ' '); + // Msg owns the row itself, so it does not announce over its own text. try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); try std.testing.expectEqualStrings("hello", pane.msg[0..pane.msg_len]); @@ -14668,6 +14820,86 @@ test "a builtin announces itself on the message row, and Verbose silences it" { try std.testing.expectEqual(@as(u16, 0), pane.msg_len); } +test "notices stack downward from the top of the body, which starts below them" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const id = p.active; + const pane = p.panes[id].?; + p.sync(); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + + const r = p.rects[id]; + const plain = p.bodyTop(pane, r); + try std.testing.expectEqual(@as(u16, 0), pane.body_offset); + + // A message and a leader prefix at once: two bands, stacked in the order + // collectNotices chose, each one row tall and each one row further down. + try std.testing.expect(p.executeBuiltinLine(id, "Msg first")); + p.leader_on = true; + p.leader_n = 0; + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 2), pane.body_offset); + try std.testing.expectEqual(plain + 2, p.bodyTop(pane, r)); + + var rows: [Pane.Notices.max]u16 = @splat(0); + var found: usize = 0; + for (p.surface.tagLayers()) |*layer| { + if (layer.cols == 0 or layer.kind != .notice) continue; + try std.testing.expectEqual(@as(u16, 1), layer.viewport.h); + rows[found] = layer.viewport.y; + found += 1; + } + try std.testing.expectEqual(@as(usize, 2), found); + std.mem.sort(u16, rows[0..2], {}, std.sort.asc(u16)); + try std.testing.expectEqual(plain, rows[0]); + try std.testing.expectEqual(plain + 1, rows[1]); + + // Gone with the notices: no stale band, and the body back at the top. + p.leader_on = false; + pane.msg_len = 0; + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 0), pane.body_offset); + try std.testing.expectEqual(plain, p.bodyTop(pane, r)); + for (p.surface.tagLayers()) |*layer| + try std.testing.expect(layer.cols == 0 or layer.kind != .notice); +} + +test "a prompt band keeps its caret on the grid, and a band is not a click target" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const id = p.active; + const pane = p.panes[id].?; + p.sync(); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + + // The caret sits AFTER the prompt text the moment the prompt opens, and + // the band is flush with the pane's right edge -- so this is the frame + // that used to put it one column past the pane, off the grid and past + // what the detached wire will encode. + p.startPrompt(pane, .{ .search = "/" }); + _ = try p.render(frame.allocator()); + const r = p.rects[id]; + const cursor = p.surface.cursor orelse return error.MissingPromptCursor; + try std.testing.expect(pane.body_offset > 0); + try std.testing.expectEqual(p.bodyTop(pane, r) - 1, cursor.y); + try std.testing.expect(cursor.x < r.x + r.w); + try std.testing.expect(cursor.x < p.surface.cols); + + // The band is chrome. Mapped as body it would land on Sel row 0, which is + // the TAG row, so a click on it would run a word out of the tag as a + // builtin. + const band_row = p.bodyTop(pane, r) - 1; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + r.w - 4, .row = band_row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + r.w - 4, .row = band_row } }); + for (pane.sel) |selection| try std.testing.expect(selection.state == .none); +} + test "closing the last pane in a column leaves an empty one in its place" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); |
