From 8fc6bb0633d7992dad6b28a2d36730689226e096 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 23:23:22 -0300 Subject: Stack a pane's transient lines instead of letting the last one win the row A pane's message, its pending leader chord and a prompt waiting for input all wanted the same row above the tagline. The prompt won it, the leader drew over whatever was there on the right, and renderBodyLayer recomputed "is anything down there" inline to reserve a single row. Three claimants, one row, and two places deciding. Pane.Notices is that decision in one place: a short ordered list of the lines a pane is showing, rebuilt every frame by collectNotices from the state that owns each one. The body layer reserves exactly notices.len rows, the way it already reserves rows for sticky context headers, and the paint pass walks the list and gives each line a row of its own, stacked upward from the tagline. Nothing stores a second copy of the truth, so a line that goes away is simply not added next frame and the rest close the gap. One notice lands on exactly the row the message always had, and the prompt stays nearest the tagline so it keeps its cursor. Last also learned what to do when the jumplist is empty. It used to walk the jumps and, finding nothing, do nothing at all -- which is the ordinary case for a pane that opened beside this one and was never focused, such as the text pane the bare tty layout puts under the shell. It now falls back to neighbourPane: the next pane down the column, wrapping, and any other live pane failing that. Alternating with the neighbour is what Last is for. Co-Authored-By: Claude Opus 5 (1M context) --- features.txt | 3 ++ src/builtins.zig | 3 ++ src/panes.zig | 28 +++++++++++++++ src/pardes.zig | 102 ++++++++++++++++++++++++++++++++++++------------------- 4 files changed, 102 insertions(+), 34 deletions(-) diff --git a/features.txt b/features.txt index 7515d455..e05f6f07 100644 --- a/features.txt +++ b/features.txt @@ -136,3 +136,6 @@ gamepad subsystem. Two fields and a branch for nothing, so it went back out. Conclusion for both platforms: nothing in pardes's own logic is slow at startup. The cost is the Debug build's allocator tracing and the GPU/driver stack coming up, and the only lever on either is the build mode. + +The Last builtin should fall back to a heuristic when the jumplist has nothing to offer, so the bare tty layout -- a shell with an unfocused text pane under it -- +alternates with Last even though that text pane was never focused and is not on the jumplist. diff --git a/src/builtins.zig b/src/builtins.zig index aa2b2d6d..30a5a50f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -701,6 +701,9 @@ pub const Last = struct { // Restore the cursor without recentering the pane's retained view. if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .keep); } + // Nothing on the jumplist to go back to, which is the ordinary case + // for a pane that opened beside this one and was never focused. + if (c.p.neighbourPane(c.id)) |next| c.p.focusPaneLine(next, .{ .line = 0, .col = 0 }, .keep); } }; diff --git a/src/panes.zig b/src/panes.zig index 9a6802d0..e41698bb 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -183,6 +183,34 @@ pub const Pane = struct { tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, + /// The transient lines this pane shows between its body and its tagline: + /// a builtin's message, the pending leader chord, a prompt waiting for + /// input. Rebuilt every frame from the state that owns each one, so a + /// line that goes away is simply not added again and the rest close the + /// gap -- there is no second copy of the truth to fall out of step. The + /// body layer reserves exactly `len` rows for them, the way it already + /// reserves rows for sticky context headers. + notices: Notices = .{}, + + pub const Notices = struct { + /// Painted bottom-up in this order, so a prompt keeps the row nearest + /// the tagline that it has always had, and its cursor with it. + pub const Kind = enum { message, leader, prompt }; + pub const max = @typeInfo(Kind).@"enum".fields.len; + + kinds: [max]Kind = undefined, + len: u8 = 0, + + pub fn push(n: *Notices, kind: Kind) void { + if (n.len >= max) return; + n.kinds[n.len] = kind; + n.len += 1; + } + + pub fn slice(n: *const Notices) []const Kind { + return n.kinds[0..n.len]; + } + }; pub fn clearPointerSelection(pane: *Pane, slot: usize) void { if (pane.pointer_selections[slot]) |selection| { diff --git a/src/pardes.zig b/src/pardes.zig index 7e18db59..51442034 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -13090,6 +13090,22 @@ pub const Pardes = struct { }; } + /// The pane Last should fall back to when the jumplist has nothing: the + /// one below in this column, wrapping to the top, and any other live pane + /// failing that. A pane that opened beside this one and was never focused + /// -- the text pane the bare tty layout puts under the shell -- is not on + /// the jumplist, and alternating with it is exactly what Last is for. + pub fn neighbourPane(p: *Pardes, from: usize) ?usize { + if (layout.findPane(p, from)) |at| { + const n = p.col_n[at.col]; + if (n > 1) return p.col_panes[at.col][(at.idx + 1) % n]; + } + for (p.panes, 0..) |slot, id| { + if (slot != null and id != from) return id; + } + return null; + } + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { @@ -13359,50 +13375,54 @@ pub const Pardes = struct { pane.body_visible_rows = 0; pane.context_row_limit = null; s.body_layers[id].rows = 0; + p.collectNotices(pane, p.rects[id], id); try p.renderPane(arena, pane, p.rects[id], id, id == p.active); try p.renderBodyLayer(arena, pane, p.rects[id], id); } for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; + if (pane.notices.len == 0) continue; const r = p.rects[id]; - if (r.w <= config.GUTTER or r.h <= BOX_H) continue; - const prompt_at = pane.promptAt(); - const text = if (prompt_at) |at| - pane.tagSlice()[@min(at, pane.tag_tail_len)..] - else - pane.msg[0..pane.msg_len]; - // The pending SPC path is the third thing that wants this row, and - // it can want it while the row is otherwise empty. - const leader_here = id == p.active and p.leader_on; - if (text.len == 0 and !leader_here) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; - const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; const msg_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; - s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); - if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style); - if (leader_here) { - var ibuf: [16]u8 = @splat(' '); - @memcpy(ibuf[1..4], "SPC"); - var iw: usize = 4; - for (p.leader_keys[0..p.leader_n]) |ch| { - ibuf[iw + 1] = ch; - iw += 2; + // 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; + for (pane.notices.slice(), 0..) |kind, i| { + const row = bottom - @as(u16, @intCast(pane.notices.len - 1 - i)); + s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); + switch (kind) { + .message => _ = s.print(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; + 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); + }, + .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); + if (id != p.active) continue; + const prompt0 = (p.tagPrefix(pane) catch continue).len + at; + const col = @as(usize, pane.tag_col); + if (col >= prompt0) { + 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 }; + } + }, } - const w: u16 = @intCast(iw); - if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); - } - if (id != p.active) continue; - const at = prompt_at orelse continue; - const prompt0 = (p.tagPrefix(pane) catch continue).len + at; - const col = @as(usize, pane.tag_col); - if (col >= prompt0) { - 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 }; } } @@ -13921,12 +13941,26 @@ pub const Pardes = struct { } } + /// The one place that decides which transient lines a pane is showing and + /// in what order. Everything downstream reads the answer: the body layer + /// so it does not paint under them, and the paint pass so it can stack + /// 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; + 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); + if (pane.promptAt()) |at| { + if (pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0) pane.notices.push(.prompt); + } + // Never claim so many rows that the body has none left. + pane.notices.len = @min(pane.notices.len, r.h - BOX_H -| 1); + } + fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; if (r.w <= config.GUTTER or r.h <= BOX_H) return; - const message = if (pane.promptAt()) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0 else pane.msg_len > 0; - const reserve: u16 = if (message or (id == p.active and p.leader_on)) 1 else 0; - const viewport_rows = r.h - BOX_H -| reserve; + const viewport_rows = r.h - BOX_H -| @as(u16, pane.notices.len); if (viewport_rows == 0) return; // The canonical fixed grid remains available to terminal clients. The // separate body layer reserves enough rows for any compact font size. -- cgit v1.3