diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-05 16:28:03 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 79a8980e46d8ed1f9ef7e3f4e44db7ee58e9f88e (patch) | |
| tree | 65ebb32c55b4b91efc6f23c2142a0971a602611c /src | |
| parent | 1891a51ed2b64eec3964d3af3807be90d08a14c2 (diff) | |
| download | pardes-79a8980e46d8ed1f9ef7e3f4e44db7ee58e9f88e.tar.gz pardes-79a8980e46d8ed1f9ef7e3f4e44db7ee58e9f88e.zip | |
add the transient message row
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 12 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 10 | ||||
| -rw-r--r-- | src/message.zig | 69 | ||||
| -rw-r--r-- | src/pardes.zig | 123 | ||||
| -rw-r--r-- | src/tty/tty.zig | 12 |
5 files changed, 221 insertions, 5 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b496058e..a4883fa6 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -20,6 +20,7 @@ const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("../pardes.zig"); const config = @import("../config.zig"); const look = @import("../look.zig"); +const message = @import("../message.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("fonts.zig"); // the Font builtin's half of the seam @@ -816,6 +817,12 @@ fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes if (h == slot.*.?.hash) continue; slot.*.?.hash = h; core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); + // the hash moving IS "someone else wrote this file" (our own Save + // restamped it), so the pane says who did what to it — and it says it + // after the update, which cannot clear a message because only a key or + // a mouse event does + var mbuf: [256]u8 = undefined; + core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path)); } } @@ -2623,6 +2630,11 @@ fn drainEffects( // our own write is about to come back as a watch event: restamp // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); + // ...and say so on the pane's message row. AFTER the write, not + // beside it: every `continue` above is a save that did not happen + // and must not be reported as one. + var mbuf: [256]u8 = undefined; + core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path)); }, .new_file => |request| { var path_buf: [4096:0]u8 = undefined; diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index 6090af1a..02b72f10 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -140,11 +140,13 @@ fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void { } /// A duration cannot go in a snapshot golden — it differs every run. The -/// snapshot harness sets PARDES_LSP_NOTIME so the two introspection views -/// print `-` instead, the same way it pins PARDES_DUMP for the dump tests. -/// Nothing else reads it, and unset (i.e. every real run) shows real numbers. +/// snapshot harness sets PARDES_NOTIME so the two introspection views print `-` +/// instead, the same way it pins PARDES_DUMP for the dump tests. ONE flag, +/// meaning "no wall clock in user-visible text": message.zig reads the same one +/// to blank the transient message row's `HH:MM:SS`. Unset — i.e. every real run +/// — shows real numbers. fn hideTime() bool { - const v = std.c.getenv("PARDES_LSP_NOTIME") orelse return false; + const v = std.c.getenv("PARDES_NOTIME") orelse return false; return std.mem.span(v).len != 0; } diff --git a/src/message.zig b/src/message.zig new file mode 100644 index 00000000..0935ce96 --- /dev/null +++ b/src/message.zig @@ -0,0 +1,69 @@ +//! Native-shell ownership of the transient message row's one clock read. +//! +//! The core owns the row, the buffer and when it goes away (Pardes.setMessage); +//! what it does not own is a clock, and its header says so. So what happened is +//! narrated HERE — at the moment the IO it narrates actually finished, which is +//! also the only moment it is true: a save that failed says nothing — and handed +//! over as finished text. Both native shells post the same two events (a save, +//! an external reload), so the wording sits in one place instead of once per +//! shell, where the two copies would drift the first time either was reworded. +//! Same split, and the same reason, as temp_file.zig owning `New`'s mkstemp. +//! +//! LOCAL time, not UTC: this row is read by a person sitting in front of the +//! terminal. dump.zig stamps filenames in UTC because those are sorted, not +//! read. +const std = @import("std"); +const libc = std.c; + +/// glibc/musl/macOS `struct tm`. Only the first three fields are ever read; the +/// rest are declared because localtime_r writes the whole struct. +const Tm = extern struct { + sec: c_int, + min: c_int, + hour: c_int, + mday: c_int, + mon: c_int, + year: c_int, + wday: c_int, + yday: c_int, + isdst: c_int, + gmtoff: c_long, + zone: ?[*:0]const u8, +}; +extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; + +/// `HH:MM:SS <verb> <subject>` into `buf`, e.g. `14:32:07 saved /etc/hosts`. +/// Written into a caller buffer rather than allocated, because the core's own +/// message buffer is fixed too and a message wider than a pane is one nobody +/// reads. A subject too long for the room left is cut from the FRONT: the tail +/// of a path is the part that identifies it. +/// +/// $PARDES_NOTIME blanks the DIGITS and nothing else. The snapshot harness sets +/// it (the same flag hides the language backend's durations) because a wall +/// clock cannot live in a golden — but a message that vanished under the +/// harness could not be pinned at all, so the row still renders, still says +/// what happened, and still occupies the same columns. +pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { + var clock: [8]u8 = "--:--:--".*; + const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; + if (!notime) { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.REALTIME, &ts); + const secs: libc.time_t = ts.sec; + var tm: Tm = undefined; + if (localtime_r(&secs, &tm) != null) { + // unsigned on purpose: Zig 0.16 prints a `+` for a positive SIGNED + // int, and `{d:0>2}` on a c_int would spell 14:32:07 as +1:+3:+7 + _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ + @as(u32, @intCast(tm.hour)), + @as(u32, @intCast(tm.min)), + @as(u32, @intCast(tm.sec)), + }) catch {}; + } + } + const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; + const room = buf.len - head.len; + const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; + @memcpy(buf[head.len..][0..tail.len], tail); + return buf[0 .. head.len + tail.len]; +} diff --git a/src/pardes.zig b/src/pardes.zig index 3acf5765..7ff5ba1f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3537,6 +3537,14 @@ pub const Pane = struct { /// into write effects. Bounded: replies are tiny escape sequences. reply: [256]u8 = undefined, reply_len: u16 = 0, + /// THE TRANSIENT MESSAGE: what just happened to this pane, drawn on its + /// LAST row until the next key or mouse event wipes it (see update). Fixed + /// and inline like `reply` above — a message is one short line, so a pane + /// that never sees one still costs nothing to carry it, and there is no + /// allocation to fail at the moment something is trying to be reported. + /// Written in exactly one place, Pardes.setMessage, by a SHELL. + msg: [256]u8 = undefined, + msg_len: u16 = 0, pub fn cwdSlice(p: *const Pane) []const u8 { return p.cwd[0..p.cwd_len]; @@ -4292,6 +4300,23 @@ pub const Pardes = struct { return true; } + /// Post the transient message on `id`'s last row. Called by a SHELL once + /// the IO it narrates has actually happened, exactly as openNewFile above + /// completes a `new_file` effect: the core neither writes files nor owns a + /// clock, so both the outcome and the wall time in `text` come from there + /// (message.zig spells it, once, for both native shells). + /// + /// Nothing here decides WHEN it goes away — update does, on the next key or + /// mouse event — and nothing here knows whether the row is free: an armed + /// prompt simply outranks a message at render time, so a message posted + /// under one is stored and invisible rather than refused. + pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { + if (id >= MAX_PANES) return; + const pane = p.panes[id] orelse return; + pane.msg_len = @intCast(@min(text.len, pane.msg.len)); + @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); + } + pub fn emit(p: *Pardes, e: Effect) void { p.effects.append(p.gpa, e) catch {}; } @@ -4308,6 +4333,24 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { + // A transient message is exactly as old as your last input: touch the + // keyboard or the mouse and it is gone, on every pane, because a + // message is a report and you have just proved you are back. Only + // INPUT counts — a resize, pty output, a watch or an answering worker + // all repaint without you, and a message that a background shell could + // wipe would be one you never got to read. + // + // An ARMED PROMPT is a different occupant of the same row and is not + // touched here: it lives in tag_tail, it is what the keystroke is being + // typed INTO, and it ends at Enter or Esc. So clearing here can never + // fight one — at worst it clears something the prompt was already + // hiding. + switch (ev) { + .key, .mouse => for (p.panes) |slot| { + if (slot) |pane| pane.msg_len = 0; + }, + else => {}, + } switch (ev) { .resize => |sz| { if (comptime pdf_enabled) { @@ -11602,6 +11645,67 @@ pub const Pardes = struct { try p.renderPane(arena, pane, p.rects[id], id == p.active); } + // ---- the transient message row: a pane's LAST row, left-aligned ---- + // + // An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane + // with neither a message nor an armed prompt is not touched at all. + // Drawn after every pane's body so it lands OVER whatever that row was + // showing, and painted in the tag bar's own colours — it is the + // tagline's twin at the other end of the pane, and reading as chrome is + // what keeps it from being mistaken for content. Out here rather than + // at the end of renderPane because renderPane returns early for a + // native PDF page and for an image, and a `/` on a PDF is a real search + // whose prompt has to be visible like any other. + // + // Exactly two things can occupy the row and an ARMED PROMPT beats a + // MESSAGE, because they are not the same kind of thing: a message is a + // report of what already happened and the next keystroke wipes it, a + // prompt is what that keystroke is being typed into and it lives until + // Enter or Esc. + // + // ponytail: the row is draw-only. A click on it lands wherever the body + // under it says (tag clicks map to tag_col on the TAG row), so a prompt + // that moved down here cannot be clicked into or swept the way one on + // the tagline could — the keyboard still edits it in full. Upgrade path + // is a hit test here that maps a press on this row to tag_col + the + // marker offset, i.e. the tag row's own mapping with a constant added. + for (&p.panes, 0..) |*slot, id| { + const pane = slot.* orelse continue; + const r = p.rects[id]; + // no body row (a one-row pane, or one squeezed out entirely): the + // tag row is not ours to overwrite, so the message just waits + if (r.w <= config.GUTTER or r.h <= BOX_H) continue; + // the same one input model renderPane cut off the tagline; only one + // of the two can ever be armed (a body key arms one, exitTagEdit + // clears both) + const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null; + const text = if (prompt_at) |at| + pane.tag_tail.items[@min(at, pane.tag_tail.items.len)..] + else + pane.msg[0..pane.msg_len]; + if (text.len == 0) continue; + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + const row = r.y + r.h - 1; + // the WHOLE row, the way the tagline fills its own before printing: + // a message is a section and not a stamp, and print writes only the + // cells it needs — so without the fill the body row shows through + // to the right of a short message and reads as one garbled line. + s.fill(tx, row, tw, 1, .{ .bg = .{ .rgb = chrome.tag_bg } }); + _ = s.print(tx, row, tw, text, .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } }); + // ...and the cursor follows the text it edits. tag_col is a + // rendered-tag column, so the prompt's own column is it minus where + // the marker starts; a cursor LEFT of that is still over the part + // of the tag that stayed on the tagline, and the tag cursor + // renderPane already placed there is the right one. + if (id != p.active) continue; + const at = prompt_at orelse continue; + const prompt0 = (p.tagPrefix(pane) catch continue).len + at; + const col = @as(usize, pane.tag_col); + if (col >= prompt0 and col - prompt0 < tw) + s.cursor = .{ .x = tx + @as(u16, @intCast(col - prompt0)), .y = row, .bar = pane.mode == .insert }; + } + // global tagbar: full width, top row s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg } }); var tb_buf: [1200]u8 = undefined; @@ -11791,7 +11895,24 @@ pub const Pardes = struct { const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } }; s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } }); const tag = try p.tagText(arena, pane); - _ = s.print(tx, r.y, tw, tag, tag_style); + // An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split, + // `|` — is still TYPED INTO the tag tail: same buffer, same offsets, + // same one-line modal editor, same Enter and same Esc. Only where it is + // DRAWN moved. It is cut off the tagline here and printed on the pane's + // message row instead (see render), so the builtins in the tail stay + // readable while you type instead of being pushed off the right edge by + // a long pattern. + // + // search_at/pipe_at are TAIL offsets and tag_col is a rendered-tag + // column, so the cut is the offset plus the prefix the tail is missing + // — which is what tag.len - tail.len is, without formatting the prefix + // a second time. + const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null; + const tag_cut = if (prompt_at) |at| + @min(tag.len, tag.len - @min(tag.len, pane.tag_tail.items.len) + at) + else + tag.len; + _ = s.print(tx, r.y, tw, tag[0..tag_cut], tag_style); // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge: // the same rule as the `/` input — state you can see, nothing that // disappears — but right-aligned, so it never collides with the diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 6047e5a2..b5b4ff75 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -12,6 +12,7 @@ const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const temp_file = @import("../temp_file.zig"); +const message = @import("../message.zig"); const selection_pipe = @import("../selection_pipe.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; @@ -648,6 +649,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { if (h == slot.*.?.hash) continue; slot.*.?.hash = h; core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); + // the hash moving IS "someone else wrote this file" (our own + // Save restamped it above), so the pane says who did what to it + // — and it says it after the update, which cannot clear a + // message because only a key or a mouse event does + var mbuf: [256]u8 = undefined; + core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path)); } } @@ -882,6 +889,11 @@ fn drainEffects( // our own write is about to come back as a watch event: restamp // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); + // ...and say so on the pane's message row. AFTER the write, not + // beside it: every `continue` above is a save that did not happen + // and must not be reported as one. + var mbuf: [256]u8 = undefined; + core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path)); }, .new_file => |request| { var path_buf: [4096:0]u8 = undefined; |
