From a3ebcd08f9fb44d5e69ddafae238f83905ff5ba4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 09:49:23 -0300 Subject: Paint a prompt on its notice band, not on the body grid A builtin that asks for input (Save's path, a search, a pipe) shows its prompt as a notice chip, like Msg output and the leader chord. Messages and the chord went through a tag layer, which is what gives a chip the tagline's height, pitch and band offset; the prompt alone stayed on the grid so its caret could sit on a cell. A pixel shell therefore drew its tagline glyphs one to a BODY cell, spaced out like a banner, while the chips around it were set tight. The prompt is now a band like the others and carries its caret on the layer, at the same place in the text the grid pass puts it. The grid still paints it, for terminal clients. What a band says comes from one place, noticeText, which the grid pass and the layer pass both read, and what a prompt says from one place, Pane.promptText, which collectNotices, the painters and submitSearch share. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/panes.zig | 7 +++++++ src/pardes.zig | 60 ++++++++++++++++++++++++++++++++++++---------------------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/panes.zig b/src/panes.zig index 8d4330e9..0b565676 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -344,6 +344,13 @@ pub const Pane = struct { }; } + /// What an open prompt shows on its notice band: the one answer every + /// painter of the band reads. + pub fn promptText(p: *const Pane) ?[]const u8 { + const at = p.promptAt() orelse return null; + return p.tagSlice()[@min(at, p.tag_tail_len)..]; + } + pub fn appendTag(p: *Pane, text: []const u8) bool { if (text.len > p.tag_tail.len - p.tag_tail_len) return false; @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); diff --git a/src/pardes.zig b/src/pardes.zig index 4786bd70..29f937d7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -7356,6 +7356,17 @@ pub const Pardes = struct { }; } + /// What notice band `i` says. The grid pass and the band layers both + /// read it here, so a terminal and a pixel shell show the same line. + fn noticeText(p: *const Pardes, pane: *const Pane, i: usize, leader_buf: *[16]u8) []const u8 { + return switch (pane.notices.kinds[i]) { + .message => pane.msg[0..pane.msg_len], + .older => pane.msg_older[pane.notices.index[i]].slice(), + .leader => p.leaderText(leader_buf), + .prompt => pane.promptText() orelse "", + }; + } + /// The life of the message line notice `i` shows, if it shows one. fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife { return switch (pane.notices.kinds[i]) { @@ -9943,8 +9954,7 @@ pub const Pardes = struct { fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); - const tail = pane.tagSlice(); - const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; + const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); @@ -13997,15 +14007,7 @@ pub const Pardes = struct { // 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 = switch (kind) { - .message => pane.msg[0..pane.msg_len], - .older => pane.msg_older[pane.notices.index[i]].slice(), - .leader => p.leaderText(&leader_buf), - .prompt => blk: { - const at = pane.promptAt() orelse continue; - break :blk pane.tagSlice()[@min(at, pane.tag_tail_len)..]; - }, - }; + const text = p.noticeText(pane, i, &leader_buf); const chip = p.noticeCols(text, tw); if (chip == 0) continue; const cx = tx + tw - chip; @@ -14525,6 +14527,8 @@ pub const Pardes = struct { // 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.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; @@ -14532,14 +14536,7 @@ pub const Pardes = struct { const first = p.bodyTop(r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { - const text = switch (kind) { - .message => pane.msg[0..pane.msg_len], - .older => pane.msg_older[pane.notices.index[i]].slice(), - .leader => p.leaderText(&leader_buf), - // 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, - }; + const text = p.noticeText(pane, i, &leader_buf); if (text.len == 0) continue; const cx = pane.notices.left[i]; if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; @@ -14572,6 +14569,16 @@ pub const Pardes = struct { .h = BOX_H, }, line, blendRgb(chrome.tag_bg, page, fade), blendRgb(chrome.tag_fg, page, fade)); p.surface.tag_layers[layer_index].slide = motion.slide; + // 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) continue; + const at = pane.promptAt() orelse continue; + const prompt0 = (p.tagPrefix(pane) catch continue).len + at; + if (pane.tag_col < prompt0) continue; + const typed = panes.File.displayWidth(text[0..@min(pane.tag_col - prompt0, 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.mode == .insert }; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { @@ -14652,9 +14659,7 @@ pub const Pardes = struct { for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot); if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot); 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); - } + if (pane.promptText()) |text| if (text.len > 0) pane.notices.push(.prompt); // A pane only has so many rows to give. When they do not all fit the // LAST ones survive, because the prompt is last and it is the one // carrying the cursor: a prompt you cannot see is a prompt you type @@ -15441,7 +15446,7 @@ test "notices stack over the body's top rows without moving them" { try std.testing.expect(layer.cols == 0 or layer.kind != .notice); } -test "a prompt chip keeps its caret on the grid, and only the chip swallows a click" { +test "a prompt chip is a band with its caret, still on the grid, and only the chip swallows a click" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -15464,6 +15469,15 @@ test "a prompt chip keeps its caret on the grid, and only the chip swallows a cl try std.testing.expectEqual(row, cursor.y); try std.testing.expect(cursor.x < r.x + r.w); try std.testing.expect(cursor.x < p.surface.cols); + // A pixel shell draws the prompt from its band, in the tagline's pitch, + // with the caret right after the marker's last glyph. + const band = for (p.surface.tagLayers()) |*layer| { + if (layer.cols != 0 and layer.kind == .notice) break layer; + } else return error.NoPromptBand; + try std.testing.expectEqual(row, band.viewport.y); + const caret = band.cursor orelse return error.MissingBandCaret; + try std.testing.expect(caret.bar); + try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme()); // The chip covers only its own columns. Left of it is ordinary body, and // a sweep there still selects. -- cgit v1.3