summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig12
-rw-r--r--src/lsp/lsp_zls.zig10
-rw-r--r--src/message.zig69
-rw-r--r--src/pardes.zig123
-rw-r--r--src/tty/tty.zig12
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;