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