diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-22 02:21:03 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 43b9d696f9e25ad7589527b91a74666e71ff0472 (patch) | |
| tree | 52ed8573c53a7a6f6ca956b4f473035126a15354 /src/pardes.zig | |
| parent | 760448928186382da4ef9d942af1a2b7546ef54c (diff) | |
| download | pardes-43b9d696f9e25ad7589527b91a74666e71ff0472.tar.gz pardes-43b9d696f9e25ad7589527b91a74666e71ff0472.zip | |
Notices become tagline bands at the top of the body
A message, a leader chord and a prompt used to share one row of body text at
the bottom of a pane, wearing the tagline font and nothing else about a
tagline. Now each one is a TagLayer of its own, emitted through the same
renderHeaderLayer the pane and column tags go through, so it gets the tagline
height, the small-font metrics, the band offset and the border for free --
none of which a body-grid row can have by changing its font role. The text is
right aligned. The prompt stays on the canonical grid because it owns a
cursor, and a cursor has to sit on a real cell.
The body starts BELOW the bands rather than under them, the way tree-sitter
context rows already worked. Pane.body_offset is how many rows they took and
Pardes.bodyTop(pane, rect) is the one place that answers "where does the body
begin" -- replacing fifteen copies of `if (tag_bottom) r.y else r.y + BOX_H`
spread across the paint, hit-test, scroll, PDF and image paths, which is what
let the bands and the text under them come adrift. Every notice is painted on
the grid as well, because 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.
Three bugs the bands exposed, fixed here:
- a prompt band flush with the right edge put its caret one column past the
pane, which the detached wire refuses -- so every frame was dropped for as
long as the prompt was open. The band now reserves that column.
- a click on a band mapped to Sel row 0, which is the TAG row: clicking
chrome expanded a word out of the tagline and ran it as a builtin.
- a watched file reloading under the editor changed the core without going
through update, so needs_frame was never set and the reload was never
drawn. Pardes.invalidate() is the name for that, and the file and theme
reloads call it.
A session can now drive its own 9P namespace instead of being refused one:
ownMountSuffix answers what a path names inside this editors own tree and
resolve, readLimit and write serve it from memory rather than making the
syscall that never returns. The match is anchored to whole components under
the registrys 9p/pardes/<name>, because a bare /pardes/<name> anywhere in a
string would claim ~/src/pardes/<name>/README -- and, before write learned the
same trick, write the trees bytes over the real file. readFileLimit and
writeFile refuse instead, having no core to answer from.
A toggle setting SETS when given `on` or `off` and only flips when it is bare,
so the report LocationsConfig prints can be fed back as configuration and mean
what it says.
Snapshots: 97/98, from 0/98. The goldens were several commits stale and 17
scripts had stopped running; `config <line>` is a new script command that
appends to the per-script startup config, so a script that clicks body
coordinates pins `Verbose off` instead of counting the rows an announcement
moves. nested-optout is left failing on purpose: two levels of nesting prepend
vaxis F3 codepoints to typed lines, which is a real bug and is written down in
docs/divergences.md with a repro.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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 }); |
