summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.zig48
-rw-r--r--src/detached/client.zig26
-rw-r--r--src/detached/wire.zig7
-rw-r--r--src/file_watch.zig2
-rw-r--r--src/fs.zig124
-rw-r--r--src/gui/gui.zig25
-rw-r--r--src/panes.zig42
-rw-r--r--src/pardes.zig328
-rw-r--r--src/tag_layer.zig6
9 files changed, 505 insertions, 103 deletions
diff --git a/src/config.zig b/src/config.zig
index 81fefd2c..98bbf3f4 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -677,7 +677,9 @@ pub const Runtime = struct {
pub fn takesArg(setting: Setting) bool {
return switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur => true,
+ // A toggle flips when it is a bare tagline word and SETS when
+ // it is given `on` or `off`, so the report is configuration.
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true,
else => false,
};
}
@@ -781,9 +783,23 @@ pub const Runtime = struct {
pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool {
switch (setting.action) {
// Every toggle names a bool field of its own, so one line covers
- // all of them and a new toggle needs no arm here.
- .toggle => |field| switch (field) {
- inline else => |f| @field(state, @tagName(f)) = !@field(state, @tagName(f)),
+ // all of them and a new toggle needs no arm here. Bare, a toggle
+ // flips -- that is what a tagline word is for. With `on` or `off`
+ // it SETS, so the report `writeReport` prints (`Verbose on`) can
+ // be fed straight back as configuration and mean what it says.
+ .toggle => |field| {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ const want: ?bool = if (text.len == 0)
+ null
+ else if (std.mem.eql(u8, text, "on"))
+ true
+ else if (std.mem.eql(u8, text, "off"))
+ false
+ else
+ return false;
+ switch (field) {
+ inline else => |f| @field(state, @tagName(f)) = want orelse !@field(state, @tagName(f)),
+ }
},
.shell => {
const value = std.mem.trim(u8, argument orelse return false, " \t\r\n");
@@ -977,12 +993,34 @@ pub const Runtime = struct {
for (settings[i + 1 ..]) |later|
try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
try std.testing.expectEqual(switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true,
else => false,
}, setting.takesArg());
}
}
+ test "a toggle flips when bare and sets when told on or off" {
+ var state: Runtime = .{};
+ const verbose = find("Verbose").?;
+ try std.testing.expect(state.verbose);
+ // Bare, it flips: that is what a tagline word does.
+ try std.testing.expect(apply(&state, verbose, null));
+ try std.testing.expect(!state.verbose);
+ // Told, it SETS -- twice over, so the second call proves it is not
+ // flipping past the value it was given.
+ try std.testing.expect(apply(&state, verbose, "on"));
+ try std.testing.expect(state.verbose);
+ try std.testing.expect(apply(&state, verbose, " on\n"));
+ try std.testing.expect(state.verbose);
+ try std.testing.expect(apply(&state, verbose, "off"));
+ try std.testing.expect(!state.verbose);
+ try std.testing.expect(apply(&state, verbose, "off"));
+ try std.testing.expect(!state.verbose);
+ // And anything else is refused rather than silently flipping.
+ try std.testing.expect(!apply(&state, verbose, "maybe"));
+ try std.testing.expect(!state.verbose);
+ }
+
test "simple setting application mutates only its plain field" {
var state: Runtime = .{};
try std.testing.expect(apply(&state, find("Colors").?, null));
diff --git a/src/detached/client.zig b/src/detached/client.zig
index 9481cd54..a027c0bb 100644
--- a/src/detached/client.zig
+++ b/src/detached/client.zig
@@ -1266,8 +1266,32 @@ test "detached session: a frontend that stops reading is dropped, not waited for
// for, which is the whole claim: one slow peer costs its own slot.
try testing.expect(h.session.clients[0].attached);
try testing.expect(!h.core.quit);
+ // A message is a tagline band now, not a grid run, so "still being drawn
+ // for" is checked on the layers the frontend decoded rather than on nruns.
try good.send(.{ .event = .{ .command = "Msg still here" } });
- try testing.expect((try h.pumpUntilChange(&good)).nruns > 0);
+ try testing.expect(try pumpUntilNotice(&h, &good, "still here"));
+}
+
+/// Pump until this client has a notice band carrying `want`.
+fn pumpUntilNotice(h: *Harness, c: *Client, want: []const u8) !bool {
+ var line: [256]u8 = undefined;
+ for (0..Harness.rounds) |_| {
+ try h.pump();
+ try c.wait(5);
+ while (try c.next()) |_| {}
+ for (&c.tag_layers) |*layer| {
+ if (layer.cols == 0 or layer.kind != .notice) continue;
+ var n: usize = 0;
+ for (layer.cells[0..layer.cols]) |cell| {
+ const g = cell.grapheme();
+ if (n + g.len > line.len) break;
+ @memcpy(line[n..][0..g.len], g);
+ n += g.len;
+ }
+ if (std.mem.indexOf(u8, line[0..n], want) != null) return true;
+ }
+ }
+ return false;
}
/// A connected socket with nothing said on it yet, for the tests whose peer is
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 245bc9f4..6805d980 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -4,7 +4,10 @@ const std = @import("std");
const pardes = @import("../pardes.zig");
const limits = @import("../memory.zig").limits;
-pub const version: u16 = 6;
+/// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value
+/// an older frontend would reject mid-session. The greeting is where that has
+/// to be refused, which is what this number is for.
+pub const version: u16 = 7;
pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace };
@@ -220,7 +223,7 @@ pub const Frame = struct {
layer.viewport.y >= f.rows or (switch (layer.kind) {
.workspace => layer.id != 0,
.column => layer.id >= pardes.MAX_COLS,
- .pane => layer.id >= pardes.MAX_PANES,
+ .pane, .notice => layer.id >= pardes.MAX_PANES,
})) return error.BadValue;
const cursor = try getCursor(&r, layer.cols, 1);
layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null;
diff --git a/src/file_watch.zig b/src/file_watch.zig
index b2b7e380..b2908eb7 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -577,6 +577,7 @@ pub fn reloadTheme(
if (watches[theme_slot]) |*entry| remarkFile(entry, request.path);
}
const bytes = filesystem.readFile(gpa, request.path) catch |err| {
+ core.invalidate();
core.failThemeFile(watched.serial, err);
return false;
};
@@ -587,6 +588,7 @@ pub fn reloadTheme(
.pdf => unreachable,
}
if (!core.loadThemeFile(watched.serial, bytes, true)) return false;
+ core.invalidate();
const live = if (watches[theme_slot]) |*entry| entry else return false;
if (live.serial != watched.serial) return false;
live.generation = .{ .text = hash };
diff --git a/src/fs.zig b/src/fs.zig
index 5c75eb72..9200236d 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -65,6 +65,9 @@ pub const WriteError = error{
};
pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void {
+ // No core here to answer from, so the only safe answer is no answer: an
+ // open(2) into our own mount is the call that never returns.
+ if (isOwnMount(path)) return error.OpenFailed;
var pathbuf: [4096:0]u8 = undefined;
if (path.len >= pathbuf.len) return error.PathTooLong;
if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed;
@@ -671,6 +674,9 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409
}
if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out);
if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out);
+ // This editor's own tree, seen through a mount: answer from the tree
+ // instead of walking out into the view and back in.
+ if (ownMountSuffix(joined)) |inner| return resolveVirtual(p, inner, out);
if (resolveOs(joined, out)) |found| return found;
if (resolveVirtual(p, joined, out)) |found| return found;
if (resolveVirtual(p, word, out)) |found| return found;
@@ -682,7 +688,9 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409
}
/// The name this session is posted under, taken from its socket path.
-var own_name_buf: [64]u8 = undefined;
+/// As wide as a name a listener will accept, so there is no session whose
+/// name is too long to recognise and therefore too long to protect.
+var own_name_buf: [108]u8 = undefined;
var own_name_len: usize = 0;
pub fn noteOwnSocket(socket_path: []const u8) void {
@@ -697,35 +705,91 @@ pub fn noteOwnSocket(socket_path: []const u8) void {
own_name_len = name.len;
}
-/// Is this path inside this editor's OWN 9P tree, as a mount presents it?
+/// What this path names inside this editor's OWN 9P tree, if it does:
+/// `/mnt/9p/pardes/<me>/pane/3/body` -> `pane/3/body`, and the mount root
+/// itself -> `/`.
///
-/// Touching one from inside the editor deadlocks the session outright: the
-/// realpath, the stat and the read all leave through the mount and come back
-/// as 9P requests that only the editor's loop can answer, while that loop is
-/// blocked making them. The filesystem then stops answering anybody. So the
-/// answer has to come from the NAME, before any syscall -- the syscall is the
-/// thing that never returns. Look at `/n/self/...` instead, which the editor
-/// serves from memory without leaving the process.
-pub fn isOwnMount(path: []const u8) bool {
- if (own_name_len == 0) return false;
+/// A mount is a VIEW of a tree this editor already holds. Going out through
+/// the view to reach it deadlocks the session outright -- the realpath, the
+/// stat and the read all leave through the mount and come back as 9P requests
+/// only this editor's loop can answer, while that loop is blocked making them,
+/// and the filesystem then stops answering anybody. So the view is recognised
+/// by NAME, before any syscall (the syscall is the thing that never returns),
+/// and the request is served from the tree directly. Same answer, no round
+/// trip, and a session can drive itself through its own 9P namespace.
+pub fn ownMountSuffix(path: []const u8) ?[]const u8 {
+ if (own_name_len == 0 or path.len == 0 or path[0] != '/') return null;
+ // Whole components, and the registry's own layout: a session is posted at
+ // `<runtime>/9p/pardes/<name>` and mounts group under `/mnt/9p/pardes/`.
+ // Matching a bare `/pardes/<name>` anywhere in the string would claim
+ // `~/src/pardes/<name>/README` -- an ordinary directory that happens to
+ // read like a mount -- and serve the tree's README over the real file.
var buf: [own_name_buf.len + 16]u8 = undefined;
- const needle = std.fmt.bufPrint(&buf, "/pardes/{s}/", .{own_name_buf[0..own_name_len]}) catch return false;
- return std.mem.indexOf(u8, path, needle) != null;
+ const stem = std.fmt.bufPrint(&buf, "/9p/pardes/{s}", .{own_name_buf[0..own_name_len]}) catch return null;
+ var at: usize = 0;
+ while (std.mem.indexOfPos(u8, path, at, stem)) |found| : (at = found + 1) {
+ const rest = path[found + stem.len ..];
+ if (rest.len != 0 and rest[0] != '/') continue; // a longer name that merely starts the same
+ if (rest.len <= 1) return "/";
+ return rest[1..];
+ }
+ return null;
+}
+
+pub fn isOwnMount(path: []const u8) bool {
+ return ownMountSuffix(path) != null;
}
-test "a path inside this session's own mount is refused before any syscall" {
+test "a path inside this session's own mount is answered from the tree, not through the mount" {
noteOwnSocket("/run/user/1000/pardes-9p-demo.sock");
defer own_name_len = 0;
- try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/index"));
- try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/pane/new"));
- try std.testing.expect(!isOwnMount("/mnt/9p/pardes/other/index"));
- try std.testing.expect(!isOwnMount("/home/goblin/src/pardes/demo.zig"));
+ // What the path names inside the tree, with the mount prefix taken off.
+ try std.testing.expectEqualStrings("index", ownMountSuffix("/mnt/9p/pardes/demo/index").?);
+ try std.testing.expectEqualStrings("pane/new", ownMountSuffix("/mnt/9p/pardes/demo/pane/new").?);
+ try std.testing.expectEqualStrings("/", ownMountSuffix("/mnt/9p/pardes/demo").?);
+ try std.testing.expectEqualStrings("/", ownMountSuffix("/mnt/9p/pardes/demo/").?);
+ // The registry posts the session there too, so that spelling counts.
+ try std.testing.expectEqualStrings("index", ownMountSuffix("/run/user/1000/9p/pardes/demo/index").?);
+ // Another session's mount is somebody else's to answer, and a name that
+ // merely STARTS with ours is a different name.
+ try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/other/index") == null);
+ try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/demo2/index") == null);
+ // An ordinary directory that reads like a mount is an ordinary directory.
+ // Serving the tree here would hand back the tree's README for the file on
+ // disk, and -- before `write` learned the same trick -- write the tree's
+ // bytes over it.
+ try std.testing.expect(ownMountSuffix("/home/goblin/src/pardes/demo/README") == null);
+ try std.testing.expect(ownMountSuffix("/home/goblin/src/pardes/demo.zig") == null);
+ // Relative paths never name a mount: they are resolved against a cwd first.
+ try std.testing.expect(ownMountSuffix("mnt/9p/pardes/demo/index") == null);
+
+ // The syscall is the thing that never returns, so it is never made.
var out: [4096]u8 = undefined;
try std.testing.expect(resolveOs("/mnt/9p/pardes/demo/index", &out) == null);
- // A session with no listener has no name, so nothing is refused.
+ // A session with no listener has no name, so nothing is redirected.
noteOwnSocket("/tmp/not-a-pardes-socket");
- try std.testing.expect(!isOwnMount("/mnt/9p/pardes/demo/index"));
+ try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/demo/index") == null);
+}
+
+test "reading this session's own mount returns what the tree holds" {
+ const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ noteOwnSocket("/run/user/1000/pardes-9p-selfread.sock");
+ defer own_name_len = 0;
+
+ const direct = try read(p, "/n/self/index");
+ defer p.gpa.free(direct);
+ const mounted = try read(p, "/mnt/9p/pardes/selfread/index");
+ defer p.gpa.free(mounted);
+ try std.testing.expectEqualStrings(direct, mounted);
+ try std.testing.expect(direct.len > 0);
+
+ // A write goes to the same tree the read came from, not out through the
+ // mount: `/index` is 0400 there, and an OS write would instead try to
+ // create a file under a directory that does not exist.
+ try std.testing.expectError(error.ReadOnlyFilesystem, write(p, "/mnt/9p/pardes/selfread/index", "nope\n"));
+ try std.testing.expectError(error.ReadOnlyFilesystem, write(p, "/n/self/index", "nope\n"));
}
pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
@@ -754,7 +818,13 @@ pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 {
}
pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
- if (isOwnMount(path)) return error.OwnMount;
+ // The same view, reached by a reader that never went through `resolve`.
+ // The rewritten path cannot contain the mount stem, so this recurses once.
+ if (ownMountSuffix(path)) |inner| {
+ var self_buf: [4096]u8 = undefined;
+ const internal = std.fmt.bufPrint(&self_buf, "/n/self/{s}", .{inner}) catch return error.FileTooLarge;
+ return readLimit(p, internal, max_bytes);
+ }
const limit = @min(max_bytes, limits.max_file_bytes);
if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) {
var out: std.Io.Writer.Allocating = .init(p.gpa);
@@ -871,6 +941,14 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize
}
pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void {
+ // The same view `readLimit` answers from the tree, so a write lands where
+ // the matching read came from -- and, just as importantly, never becomes
+ // an open(2) through a mount this loop is the one that answers.
+ if (ownMountSuffix(path)) |inner| {
+ var self_buf: [4096]u8 = undefined;
+ const internal = std.fmt.bufPrint(&self_buf, "/n/self/{s}", .{inner}) catch return error.PathTooLong;
+ return write(p, internal, bytes);
+ }
if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory;
var self_path: ?[]const u8 = null;
if (std.mem.eql(u8, path, "/virtual")) self_path = "";
@@ -1233,6 +1311,10 @@ test "Restore prefers default directory then falls back to original path" {
fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed;
+ // Same reason as `writeFile`: no core to answer from here, and the open is
+ // the call that never returns. Callers holding one use `readLimit`, which
+ // serves the tree instead.
+ if (isOwnMount(path)) return error.FileNotFound;
if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) {
const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path;
var normalized_buf: [4096]u8 = undefined;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index d871e55e..c26629ab 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1788,6 +1788,7 @@ 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
@@ -4292,14 +4293,15 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
g.scroll_lag = st.lag;
g.scroll_rect = r;
- const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = core.bodyTop(pane, 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 and r.x + r.w <= s.cols) {
+ if (r.w > config.GUTTER and r.h > pardes.BOX_H + pane.body_offset and r.x + r.w <= s.cols) {
const bw = r.w - config.GUTTER;
- const bh = r.h - pardes.BOX_H;
+ const bh = (r.h - pardes.BOX_H) - pane.body_offset;
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) {
@@ -4327,7 +4329,11 @@ 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;
- const bh = r.h - pardes.BOX_H;
+ // 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;
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;
@@ -4359,7 +4365,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(r.h - pardes.BOX_H)) * layout.h;
+ const ph = @as(f32, @floatFromInt(g.scroll_body_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)));
@@ -6685,11 +6691,14 @@ fn appendPaneChrome(
const chrome = core.chromeTheme();
const page = overlayRgb(core.theme().bg orelse bg_default);
for (core.panes, core.rects) |slot, r| {
- if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue;
+ const pane = slot orelse continue;
+ if (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;
- const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- const body_end = body_y + r.h - pardes.BOX_H;
+ // 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 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 e41698bb..4b5c6721 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -191,6 +191,11 @@ 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
@@ -2036,7 +2041,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 = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = p.bodyTop(pane, r);
const context_bg = p.theme().tag_bg;
for (0..@min(pane.context_rows, body_h)) |context_row| {
for (0..tw) |col| {
@@ -2090,7 +2095,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 = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = p.bodyTop(pane, r);
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
var base: usize = undefined;
@@ -2166,7 +2171,7 @@ pub const File = struct {
pane_bg: pardes.Color,
) void {
if (tw <= gutterWidth(pane) + 1) return;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = p.bodyTop(pane, r);
const marker_fg = p.chromeTheme().lineno;
var row: u16 = 0;
while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
@@ -2190,9 +2195,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 = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = p.bodyTop(pane, r);
var vr: i32 = 0;
- while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
+ while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h -| pane.body_offset)) : (vr += 1) {
const here = pane.wrapAt(vr);
if (here.line != row) continue;
var hi = word_hi;
@@ -5477,7 +5482,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;
+ const rows = (rect.h -| pardes.BOX_H) -| pane.body_offset;
if (cols == 0 or rows == 0) return null;
return .{
.pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
@@ -5515,8 +5520,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 = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
- if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) 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 local_y = @as(f64, @floatFromInt(
@as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
));
@@ -5572,7 +5577,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, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H);
+ const body_y = @as(i64, core.bodyTop(pane, 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);
@@ -5855,7 +5860,7 @@ pub const Pdf = struct {
text_width: u16,
) bool {
if (comptime !enabled) return false;
- if (!core.native_images or rect.h <= pardes.BOX_H) return false;
+ if (!core.native_images or rect.h <= pardes.BOX_H + pane.body_offset) 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) };
@@ -5892,9 +5897,9 @@ pub const Pdf = struct {
null,
},
.x = text_x,
- .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H,
+ .y = core.bodyTop(pane, rect),
.w = text_width,
- .h = rect.h - pardes.BOX_H,
+ .h = (rect.h -| pardes.BOX_H) -| pane.body_offset,
.rgba = placed.rgba,
.iw = placed.width,
// The texture contains the retained band, not the full page.
@@ -5906,14 +5911,17 @@ pub const Pdf = struct {
// This frame has spent the motion used to choose its raster band.
state.scroll_travel = 0;
- const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const body_y = core.bodyTop(pane, rect);
+ const body_h = (rect.h -| pardes.BOX_H) -| pane.body_offset;
const chrome = core.chromeTheme();
const theme = core.theme();
const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
- core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
+ // The rail runs past the notice bands; only the thumb tracks the text.
+ const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
- const track_h: usize = rect.h - pardes.BOX_H;
+ const track_h: usize = body_h;
const total = @max(@as(u64, 1), state.document_height);
const len = @max(
@as(usize, 1),
@@ -7571,7 +7579,7 @@ pub const Terminal = struct {
if (comptime !enabled) return;
const s = &p.surface;
if (pane.terminal == null) return;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = p.bodyTop(pane, 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 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 });
diff --git a/src/tag_layer.zig b/src/tag_layer.zig
index 65990959..0fb2297d 100644
--- a/src/tag_layer.zig
+++ b/src/tag_layer.zig
@@ -3,7 +3,11 @@
const std = @import("std");
const pardes = @import("pardes.zig");
-pub const Kind = enum(u8) { workspace, column, pane };
+/// `notice` is a pane's transient line -- a builtin's name, a pending leader
+/// chord -- carried as a tagline-shaped band so it gets the tagline's height,
+/// small-font metrics, band offset and border instead of being a body-grid row
+/// wearing a tagline font.
+pub const Kind = enum(u8) { workspace, column, pane, notice };
pub const Hit = struct {
kind: Kind,
id: u16,