summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 09:31:11 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commita1d5ee19a648abc65b557dffa14a3b2f70577286 (patch)
treeef36c11168bcc2f7bbb9b7a6a29519c7e0882fab /src
parent43b9d696f9e25ad7589527b91a74666e71ff0472 (diff)
downloadpardes-a1d5ee19a648abc65b557dffa14a3b2f70577286.tar.gz
pardes-a1d5ee19a648abc65b557dffa14a3b2f70577286.zip
Notices become chips that float over the body, not rows taken from it
A notice is an overlay now, which is the one place it is NOT like the tree-sitter context: it takes no row from the body and moves no text. Each one is a chip as wide as its own message plus a blank cell either side, anchored to the pane right edge on the body top rows. The rest of each of those rows is ordinary body text that still reads and still answers a click -- Notices.left records where each chip starts, and the hit test refuses only the cells it covers rather than the whole row. The width is counted in GRID columns rather than scaled into the tagline face the chip is drawn in, because the canonical grid is what a terminal client draws and a chip narrower than its own text there would clip it; the narrower tagline face simply leaves a little more room inside the chip. The tag layer takes the geometry the grid pass already chose, so the two cannot disagree and the GUI skipping the cells a tag layer covers leaves no stub behind. Pane.body_offset is gone with the reservation it existed for, and bodyTop takes only a rect. The body layer keeps every row it had, the scrollbar runs the full body again, and the GUI smooth-scroll extents go back to the pane. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig25
-rw-r--r--src/panes.zig46
-rw-r--r--src/pardes.zig298
3 files changed, 193 insertions, 176 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index c26629ab..3c2fd693 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1788,7 +1788,6 @@ const Gui = struct {
scroll_pane: ?usize = null,
scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it)
- scroll_body_h: u16 = 0, // and how many rows it has, notice bands taken off
scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered
scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since
scroll_delta: f32 = 0, // finite raw distance waiting for the render batch
@@ -4293,15 +4292,14 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
g.scroll_lag = st.lag;
g.scroll_rect = r;
- const body_y = core.bodyTop(pane, r);
+ const body_y = core.bodyTop(r);
g.scroll_body_y = body_y;
- g.scroll_body_h = (r.h -| pardes.BOX_H) -| pane.body_offset;
if (st.rows != 0) {
const s = &core.surface;
g.scroll_edge_len = 0;
- if (r.w > config.GUTTER and r.h > pardes.BOX_H + pane.body_offset and r.x + r.w <= s.cols) {
+ if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) {
const bw = r.w - config.GUTTER;
- const bh = (r.h - pardes.BOX_H) - pane.body_offset;
+ const bh = r.h - pardes.BOX_H;
const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh)));
const srow = body_y + (if (st.rows > 0) k - 1 else bh - k);
if (srow < s.rows) {
@@ -4329,11 +4327,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const x0 = r.x + config.GUTTER;
const y0 = g.scroll_body_y;
const bw = r.w - config.GUTTER;
- // The body the editor actually painted: measuring it from the rect while
- // the origin comes from bodyTop reads past the pane by the band rows, and
- // for the bottom-most pane past the surface, which drops the animation.
- const bh = g.scroll_body_h;
- if (bh == 0) return 0;
+ const bh = r.h - pardes.BOX_H;
if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us
var shifted = layout;
shifted.y_off -= g.scroll_lag * layout.h;
@@ -4365,7 +4359,7 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect
const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w;
const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h;
const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w;
- const ph = @as(f32, @floatFromInt(g.scroll_body_h)) * layout.h;
+ const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h;
const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw)));
const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh)));
const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw)));
@@ -6691,14 +6685,11 @@ fn appendPaneChrome(
const chrome = core.chromeTheme();
const page = overlayRgb(core.theme().bg orelse bg_default);
for (core.panes, core.rects) |slot, r| {
- const pane = slot orelse continue;
- if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue;
+ if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue;
if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue;
const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y;
- // The rail the editor painted, notice bands included -- overlaying the
- // rows above it would tint the bands instead of the scrollbar.
- const body_y = core.bodyTop(pane, r);
- const body_end = body_y + ((r.h -| pardes.BOX_H) -| pane.body_offset);
+ const body_y = core.bodyTop(r);
+ const body_end = body_y + (r.h -| pardes.BOX_H);
const x0 = @as(f32, @floatFromInt(r.x)) * layout.w;
const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w;
const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET];
diff --git a/src/panes.zig b/src/panes.zig
index 4b5c6721..f995986b 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -191,11 +191,6 @@ pub const Pane = struct {
/// body layer reserves exactly `len` rows for them, the way it already
/// reserves rows for sticky context headers.
notices: Notices = .{},
- /// Rows the notice bands take off the TOP of the body, the way tree-sitter
- /// context rows do: the body starts below them rather than being painted
- /// under them. Zero while the body LAYER renders, which paints into a
- /// surface of its own that carries no bands.
- body_offset: u16 = 0,
pub const Notices = struct {
/// Painted bottom-up in this order, so a prompt keeps the row nearest
@@ -204,14 +199,25 @@ pub const Pane = struct {
pub const max = @typeInfo(Kind).@"enum".fields.len;
kinds: [max]Kind = undefined,
+ /// Where each band's chip starts, as an absolute grid column. A chip
+ /// is only as wide as its own text, so the rest of the row underneath
+ /// is ordinary body text and a click there has to reach it.
+ left: [max]u16 = @splat(std.math.maxInt(u16)),
len: u8 = 0,
pub fn push(n: *Notices, kind: Kind) void {
if (n.len >= max) return;
n.kinds[n.len] = kind;
+ n.left[n.len] = std.math.maxInt(u16);
n.len += 1;
}
+ /// Is this cell inside the chip drawn for band `i`? Nothing else on
+ /// that row belongs to the notice.
+ pub fn covers(n: *const Notices, i: usize, col: u16) bool {
+ return i < n.len and col >= n.left[i];
+ }
+
pub fn slice(n: *const Notices) []const Kind {
return n.kinds[0..n.len];
}
@@ -2041,7 +2047,7 @@ pub const File = struct {
const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff;
const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
// the body's first row, the way renderPane derives it (Tagbottom)
- const body_y = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
const context_bg = p.theme().tag_bg;
for (0..@min(pane.context_rows, body_h)) |context_row| {
for (0..tw) |col| {
@@ -2095,7 +2101,7 @@ pub const File = struct {
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
- const body_y = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
var base: usize = undefined;
@@ -2171,7 +2177,7 @@ pub const File = struct {
pane_bg: pardes.Color,
) void {
if (tw <= gutterWidth(pane) + 1) return;
- const body_y = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
const marker_fg = p.chromeTheme().lineno;
var row: u16 = 0;
while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
@@ -2195,9 +2201,9 @@ pub const File = struct {
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
const prefix_width = gutterWidth(pane);
- const body_y = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
var vr: i32 = 0;
- while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h -| pane.body_offset)) : (vr += 1) {
+ while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
const here = pane.wrapAt(vr);
if (here.line != row) continue;
var hi = word_hi;
@@ -5482,7 +5488,7 @@ pub const Pdf = struct {
if (slot == pane) break core.rects[id];
} else return null;
const cols = rect.w -| config.GUTTER;
- const rows = (rect.h -| pardes.BOX_H) -| pane.body_offset;
+ const rows = rect.h -| pardes.BOX_H;
if (cols == 0 or rows == 0) return null;
return .{
.pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
@@ -5520,8 +5526,8 @@ pub const Pdf = struct {
const rect = for (core.panes, 0..) |slot, id| {
if (slot == pane) break core.rects[id];
} else return null;
- const body_y = core.bodyTop(pane, rect);
- if (row < body_y or row >= body_y + ((rect.h -| pardes.BOX_H) -| pane.body_offset)) return null;
+ const body_y = core.bodyTop(rect);
+ if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
const local_y = @as(f64, @floatFromInt(
@as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
));
@@ -5577,7 +5583,7 @@ pub const Pdf = struct {
if (slot == pane) break core.rects[id];
} else return null;
const body_x = @as(i64, rect.x + config.GUTTER);
- const body_y = @as(i64, core.bodyTop(pane, rect));
+ const body_y = @as(i64, core.bodyTop(rect));
const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
return pointAtPage(state, view, page, px, py, clamp_to_page);
@@ -5860,7 +5866,7 @@ pub const Pdf = struct {
text_width: u16,
) bool {
if (comptime !enabled) return false;
- if (!core.native_images or rect.h <= pardes.BOX_H + pane.body_offset) return false;
+ if (!core.native_images or rect.h <= pardes.BOX_H) return false;
const state = &(pane.pdf orelse return false);
const view = paneViewport(core, pane) orelse return false;
const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
@@ -5897,9 +5903,9 @@ pub const Pdf = struct {
null,
},
.x = text_x,
- .y = core.bodyTop(pane, rect),
+ .y = core.bodyTop(rect),
.w = text_width,
- .h = (rect.h -| pardes.BOX_H) -| pane.body_offset,
+ .h = rect.h -| pardes.BOX_H,
.rgba = placed.rgba,
.iw = placed.width,
// The texture contains the retained band, not the full page.
@@ -5911,8 +5917,8 @@ pub const Pdf = struct {
// This frame has spent the motion used to choose its raster band.
state.scroll_travel = 0;
- const body_y = core.bodyTop(pane, rect);
- const body_h = (rect.h -| pardes.BOX_H) -| pane.body_offset;
+ const body_y = core.bodyTop(rect);
+ const body_h = rect.h -| pardes.BOX_H;
const chrome = core.chromeTheme();
const theme = core.theme();
const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
@@ -7579,7 +7585,7 @@ pub const Terminal = struct {
if (comptime !enabled) return;
const s = &p.surface;
if (pane.terminal == null) return;
- const body_y = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
var filtered_storage: FilteredColors = undefined;
const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
const tz_filter = tracy.zone(@src(), "filterInit");
diff --git a/src/pardes.zig b/src/pardes.zig
index c271a16f..0ac16162 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1168,29 +1168,30 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest
pane.msg[0] = '!';
pane.msg_len = 1;
_ = try p.render(p.scratch.allocator());
- try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h);
+ // An overlay takes nothing: the message is a chip OVER the body's first
+ // row, so the viewport keeps every row it had.
+ try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h);
const r = p.rects[0];
- // 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);
+ try std.testing.expectEqual(p.bodyTop(r), band.viewport.y);
+ try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y);
+ // Sized to its message, flush with the pane's right edge, and the text
+ // right aligned a cell short of that edge.
+ try std.testing.expectEqual(r.x + r.w, band.viewport.x + band.viewport.w);
+ try std.testing.expect(band.viewport.w < r.w - config.GUTTER);
+ try std.testing.expectEqualStrings("!", band.cells[p.tagCapacity(band.viewport.w) - 2].grapheme());
pane.msg_len = 0;
p.leader_on = true;
_ = try p.render(p.scratch.allocator());
- try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h);
+ try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h);
p.leader_on = false;
try std.testing.expect(p.executeBuiltinLine(0, "TreeContextTagStyle"));
_ = 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, p.bodyTop(pane, r)).style.font_role);
+ try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(r)).style.font_role);
try p.dumpState();
const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
@@ -6282,6 +6283,33 @@ pub const Pardes = struct {
return p;
}
+ /// `SPC` plus the chord keys typed so far, spaced out the way the help
+ /// index spells a leader path.
+ fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 {
+ buf.* = @splat(' ');
+ @memcpy(buf[0..3], "SPC");
+ var at: usize = 3;
+ for (p.leader_keys[0..p.leader_n]) |ch| {
+ if (at + 2 > buf.len) break;
+ buf[at + 1] = ch;
+ at += 2;
+ }
+ return buf[0..at];
+ }
+
+ /// How wide a notice chip is, in grid columns: its own text plus a blank
+ /// cell either side. A notice is an OVERLAY over the body's top rows, not
+ /// a row taken from them, so it is sized to its message and never to the
+ /// pane. Counted in GRID columns rather than scaled into the tagline face
+ /// it is drawn in, because the canonical grid is what a terminal client
+ /// draws and a chip narrower than its own text there would clip it; the
+ /// tagline face simply leaves a little more room inside the chip.
+ fn noticeCols(p: *const Pardes, text: []const u8, limit: u16) u16 {
+ _ = p;
+ if (limit == 0) return 0;
+ return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit)));
+ }
+
/// 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 {
@@ -11264,13 +11292,14 @@ 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 = p.bodyTop(p.panes[id].?, r);
+ const body_y = p.bodyTop(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;
+ // A notice chip is chrome: it sits OVER the body's top rows, so a
+ // click inside one belongs to nobody. The rest of those rows is
+ // ordinary body text and still answers normally.
+ if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.len and
+ p.panes[id].?.notices.covers(row - body_y, col)) 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;
@@ -11657,7 +11686,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
@@ -11721,7 +11750,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(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);
@@ -11818,8 +11847,8 @@ pub const Pardes = struct {
// 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;
+ const body_y = p.bodyTop(r);
+ const body_h = r.h -| BOX_H;
pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
0
else
@@ -13370,12 +13399,11 @@ 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;
+ /// The pane's first body row: under the tag when it is on top, over it
+ /// when it is at the bottom. Notice bands do NOT move it -- they are an
+ /// overlay, drawn over the top rows rather than taking them.
+ pub fn bodyTop(p: *const Pardes, r: Rect) u16 {
+ return if (p.settings.tag_bottom) r.y else r.y + BOX_H;
}
fn tagCapacity(p: *const Pardes, physical_width: u16) u16 {
@@ -13461,12 +13489,13 @@ pub const Pardes = struct {
.bg = .{ .rgb = chrome.tag_bg },
.font_role = .tagline,
};
- // 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;
+ // An OVERLAY over the body's top rows, at the right edge, one
+ // chip to a row in the order collectNotices chose -- not a row
+ // taken out of the body the way a tree-sitter context header is.
+ // The chip is as wide as its own message and no wider, so the text
+ // beneath the rest of the row keeps reading as the file it is.
+ const first = p.bodyTop(r);
+ var leader_buf: [16]u8 = undefined;
for (pane.notices.slice(), 0..) |kind, i| {
const row = first + @as(u16, @intCast(i));
// Every notice is painted on the canonical grid as well as
@@ -13474,39 +13503,34 @@ 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.
- s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg, .font_role = .tagline });
- switch (kind) {
- .message => _ = printRight(s, tx, row, tw, pane.msg[0..pane.msg_len], msg_style),
- .leader => {
- 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;
- }
- _ = printRight(s, tx, row, tw, ibuf[0..iw], msg_style);
- },
- .prompt => {
+ const text = switch (kind) {
+ .message => pane.msg[0..pane.msg_len],
+ .leader => p.leaderText(&leader_buf),
+ .prompt => blk: {
const at = pane.promptAt() orelse continue;
- const text = pane.tagSlice()[@min(at, pane.tag_tail_len)..];
- // 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 = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert };
- }
+ break :blk pane.tagSlice()[@min(at, pane.tag_tail_len)..];
},
- }
+ };
+ const chip = p.noticeCols(text, tw);
+ if (chip == 0) continue;
+ const cx = tx + tw - chip;
+ pane.notices.left[i] = cx;
+ s.fill(cx, row, chip, 1, .{ .bg = msg_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 left = printRight(s, cx, row, chip -| 1, text, msg_style);
+ if (kind != .prompt or id != p.active) continue;
+ const at = pane.promptAt() orelse continue;
+ const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
+ const col = @as(usize, pane.tag_col);
+ if (col < prompt0) continue;
+ // The cursor follows the text to wherever it landed.
+ const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]);
+ if (left + prompt_col < tx + tw)
+ s.cursor = .{ .x = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert };
}
}
@@ -13829,11 +13853,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(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) -| pane.body_offset) break;
+ if (visible >= r.h -| BOX_H) 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)
@@ -13871,10 +13895,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(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) -| pane.body_offset)) : (visible += 1) {
+ while (visible < @as(i32, r.h -| BOX_H)) : (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))));
@@ -13919,11 +13943,6 @@ 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;
@@ -13972,48 +13991,45 @@ 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.
+ // 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.
+ // 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.
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 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],
- .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]);
- },
+ .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,
};
if (text.len == 0) continue;
- const room = p.tagCapacity(r.w -| TAG_TEXT_INSET);
+ const cx = pane.notices.left[i];
+ if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
+ const chip = r.x + r.w - cx;
+ // Right aligned inside the chip, a blank cell short of its
+ // edge: the same one the grid pass leaves for a prompt caret.
+ const room = p.tagCapacity(chip) -| 1;
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,
+ try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{
+ .x = cx,
+ .y = first + @as(u16, @intCast(i)),
+ .w = chip,
.h = BOX_H,
}, line, chrome.tag_bg, chrome.tag_fg);
- band += 1;
}
}
if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
@@ -14087,7 +14103,6 @@ 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);
@@ -14105,13 +14120,12 @@ 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 {
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 viewport_rows = r.h - BOX_H -| @as(u16, pane.notices.len);
+ const viewport_rows = r.h - BOX_H;
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.
@@ -14132,9 +14146,6 @@ 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);
@@ -14148,11 +14159,7 @@ pub const Pardes = struct {
}
layer.pane = @intCast(id);
layer.serial = pane.serial;
- // 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.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(r), .w = width, .h = viewport_rows };
layer.cols = width;
layer.rows = @intCast(logical_rows);
layer.context_rows = pane.context_rows;
@@ -14174,7 +14181,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(r);
const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
// text area resets to terminal-default cells (vaxis clear semantics);
@@ -14214,7 +14221,7 @@ pub const Pardes = struct {
if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return;
if (pane.image) |*iv| {
- const image_h = (r.h -| BOX_H) -| pane.body_offset;
+ const image_h = r.h -| BOX_H;
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, and like that one it runs
@@ -14235,7 +14242,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 = p.bodyTop(pane, r);
+ const body_y = p.bodyTop(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;
@@ -14322,12 +14329,12 @@ pub const Pardes = struct {
// 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 body_y = p.bodyTop(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 = (r.h -| BOX_H) -| pane.body_offset;
- const body_bottom = r.h -| pane.body_offset;
+ const body_h = r.h -| BOX_H;
+ const body_bottom = r.h;
const body_style: CellStyle = .{
.fg = if (th.fg) |c| .{ .rgb = c } else .default,
.bg = pane_bg,
@@ -14820,7 +14827,7 @@ 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" {
+test "notices stack over the body's top rows without moving them" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -14831,44 +14838,45 @@ test "notices stack downward from the top of the body, which starts below them"
defer frame.deinit();
const r = p.rects[id];
- const plain = p.bodyTop(pane, r);
- try std.testing.expectEqual(@as(u16, 0), pane.body_offset);
+ const top = p.bodyTop(r);
- // A message and a leader prefix at once: two bands, stacked in the order
+ // A message and a leader prefix at once: two chips, 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));
+ try std.testing.expectEqual(@as(u8, 2), pane.notices.len);
+ // An overlay, not a reservation: the body still starts where it did.
+ try std.testing.expectEqual(top, p.bodyTop(r));
- var rows: [Pane.Notices.max]u16 = @splat(0);
- var found: usize = 0;
+ var seen: 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;
+ // Sized to its own message, right up against the pane's right edge --
+ // not the width of the pane.
+ try std.testing.expectEqual(r.x + r.w, layer.viewport.x + layer.viewport.w);
+ try std.testing.expect(layer.viewport.w < r.w - config.GUTTER);
+ try std.testing.expect(layer.viewport.y == top or layer.viewport.y == top + 1);
+ try std.testing.expectEqual(pane.notices.left[layer.viewport.y - top], layer.viewport.x);
+ seen += 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]);
+ try std.testing.expectEqual(@as(usize, 2), seen);
+ // "first" is longer than "SPC", so its chip is wider.
+ try std.testing.expect(pane.notices.left[0] < pane.notices.left[1]);
- // Gone with the notices: no stale band, and the body back at the top.
+ // Gone with the notices: no stale chip left behind.
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" {
+test "a prompt chip keeps its caret 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();
@@ -14879,24 +14887,36 @@ test "a prompt band keeps its caret on the grid, and a band is not a click targe
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.
+ // the chip ends at 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);
+ const row = p.bodyTop(r);
+ try std.testing.expectEqual(@as(u8, 1), pane.notices.len);
+ 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);
- // 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 } });
+ // The chip covers only its own columns. Left of it is ordinary body, and
+ // a sweep there still selects.
+ const left = pane.notices.left[0];
+ try std.testing.expect(left > r.x + config.GUTTER + 2);
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = left - 3, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = left - 1, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = left - 1, .row = row } });
+ try std.testing.expect(pane.sel[0].state != .none);
+ try std.testing.expect(pane.sel[0].r0 >= BOX_H); // the body, not the tag
+
+ // Inside it is chrome: mapped as body it would land on Sel row 0, which is
+ // the TAG row, so a sweep there would highlight the tagline instead.
+ pane.clearPointerSelection(0);
+ pane.sel[0] = .{};
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + r.w - 3, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = r.x + r.w - 2, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + r.w - 2, .row = row } });
for (pane.sel) |selection| try std.testing.expect(selection.state == .none);
}