summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/Messages.zig773
-rw-r--r--src/Output.zig3
-rw-r--r--src/detached/server.zig2
-rw-r--r--src/file_watch.zig2
-rw-r--r--src/fs.zig1
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/macos.zig2
-rw-r--r--src/ninep/events.zig2
-rw-r--r--src/pardes.zig790
-rw-r--r--src/tty/tty.zig2
10 files changed, 805 insertions, 774 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
new file mode 100644
index 00000000..6b041369
--- /dev/null
+++ b/src/Messages.zig
@@ -0,0 +1,773 @@
+//! The message row: messages and statuses a pane shows between its body and
+//! its tagline, how each line falls in, lingers and dissolves, the notices
+//! built from them every frame, and the log of everything said.
+const pardes = @import("pardes.zig");
+const memory = @import("memory.zig");
+const std = @import("std");
+const layout = @import("layout.zig");
+const ctlfs = @import("ninep/tree.zig");
+const panes = @import("panes.zig");
+const CellStyle = @import("surface.zig").CellStyle;
+const Surface = @import("surface.zig").Surface;
+const limits = memory.limits;
+const config = @import("config.zig");
+const platform = pardes.platform;
+const Pane = panes.Pane;
+const MAX_PANES = pardes.MAX_PANES;
+const BOX_H = pardes.BOX_H;
+const Key = pardes.Key;
+const Rect = layout.Rect;
+const Pardes = pardes.Pardes;
+
+pub const LoggedMessage = struct {
+ pub const cap = 256;
+ text: [cap]u8 = undefined,
+ len: u16 = 0,
+ /// The serial of the pane that said it, or 0 for none in particular. A
+ /// serial, not a slot: slots are reused, so an old line would name
+ /// whichever pane took the slot since.
+ serial: u32 = 0,
+ /// How many times in a row. Saturating: a key held down against the same
+ /// refusal is one event, not four hundred.
+ repeats: u16 = 0,
+
+ pub fn slice(m: *const LoggedMessage) []const u8 {
+ return m.text[0..m.len];
+ }
+};
+
+/// `SPC` plus the chord keys typed so far, spaced out the way the help
+/// index spells a leader path.
+fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 {
+ buf.* = @splat(' ');
+ @memcpy(buf[0..3], "SPC");
+ var at: usize = 3;
+ for (p.leader_keys[0..p.leader_n]) |ch| {
+ if (at + 2 > buf.len) break;
+ buf[at + 1] = ch;
+ at += 2;
+ }
+ return buf[0..at];
+}
+
+/// How wide a notice chip is, in grid columns: its own text plus a blank
+/// cell either side. A notice is an OVERLAY over the body's top rows, not
+/// a row taken from them, so it is sized to its message and never to the
+/// pane. Counted in GRID columns rather than scaled into the tagline face
+/// it is drawn in, because the canonical grid is what a terminal client
+/// draws and a chip narrower than its own text there would clip it; the
+/// tagline face simply leaves a little more room inside the chip.
+pub fn noticeCols(p: *const Pardes, text: []const u8, limit: u16) u16 {
+ _ = p;
+ if (limit == 0) return 0;
+ return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit)));
+}
+
+const Printed = struct { left: u16, dropped: usize };
+
+/// Prints `text` flush with the right edge of the band, and answers the
+/// column it started at so a cursor can follow it. Text wider than the
+/// band loses its HEAD: the end is the part that says something -- a
+/// path's file name, an error's reason -- and `dropped` is how many
+/// columns of it went.
+pub fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) Printed {
+ const width = panes.File.displayWidth(text);
+ if (width <= w) {
+ const left: u16 = @intCast(x + w - width);
+ _ = s.print(left, row, @intCast(width), text, style);
+ return .{ .left = left, .dropped = 0 };
+ }
+ // Whole glyphs only: a cut inside a wide one would leave the rest a
+ // column too wide and clip its LAST glyph instead of the first.
+ var dropped = width - w;
+ var start = panes.File.rawAtDisplay(text, dropped);
+ if (panes.File.rawDisplayCol(text, start) < dropped) {
+ dropped += 1;
+ start = panes.File.rawAtDisplay(text, dropped);
+ }
+ const left: u16 = @intCast(x + w - (width - dropped));
+ _ = s.print(left, row, @intCast(width - dropped), text[start..], style);
+ return .{ .left = left, .dropped = dropped };
+}
+
+pub const Message = struct {
+ const libc = std.c;
+ 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;
+
+ pub fn body(text: []const u8) []const u8 {
+ if (text.len < 10) return text;
+ if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text;
+ for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| {
+ if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text;
+ }
+ return text[10..];
+ }
+
+ 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) {
+ _ = 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];
+ }
+};
+
+/// A status updates the message line in place (a progress count does not
+/// pile up); a message is an event, and stacks.
+pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void {
+ showMessage(p, id, text, false);
+}
+
+pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void {
+ logMessage(p, id, text);
+ showMessage(p, id, text, true);
+}
+
+fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
+ if (id >= MAX_PANES) return;
+ const pane = p.panes[id] orelse return;
+ // One row of printable text: a language server's multi-line report
+ // reads as one line, and the cut never leaves half a glyph behind.
+ var n: usize = @min(text.len, pane.msg.len);
+ if (n < text.len) {
+ while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
+ }
+ const same = pane.msg_len == n and for (text[0..n], pane.msg[0..n]) |c, have| {
+ if ((if (c < 0x20 or c == 0x7f) ' ' else c) != have) break false;
+ } else true;
+ // A new event moves the line on screen up the stack, where it keeps
+ // its own timing, and falls in below it.
+ // Only an event stacks on an event: an announcement or a status
+ // stands in for whatever comes next.
+ if (stack and pane.msg_len > 0 and !same and !pane.msg_announcement and !pane.msg_status)
+ pane.pushOlderMessage();
+ if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot();
+ pane.msg_announcement = false;
+ pane.msg_status = !stack;
+ const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving;
+ for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c;
+ pane.msg_len = @intCast(n);
+ p.needs_frame = true;
+ if (arrives) {
+ pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown };
+ } else if (pane.msg_life.phase == .lingering or
+ (pane.msg_life.phase == .entering and !p.settings.message_animation))
+ {
+ // The same line again, or a status update: fresh, so it stays.
+ pane.msg_life = .{};
+ } else {
+ pane.msg_life.dismissed = false;
+ }
+}
+
+/// The key or click that used to clear the message row now starts each
+/// line's exit: it stays `message_linger_ms`, then dissolves. A line
+/// already on its way out keeps its own timing.
+pub fn dismissMessage(p: *Pardes, pane: *Pane) void {
+ if (pane.msg_len > 0 and dismissLine(p, &pane.msg_life)) {
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ }
+ var i: usize = 0;
+ while (i < pane.msg_older_len) {
+ if (dismissLine(p, &pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
+ }
+}
+
+/// Start one line's exit; true when it is gone at once (no linger, no
+/// animation).
+fn dismissLine(p: *const Pardes, life: *Pane.MessageLife) bool {
+ switch (life.phase) {
+ .lingering, .leaving => return false,
+ // A falling line lands first, then lingers -- unless falling was
+ // just turned off, and nothing will land it.
+ .entering => if (p.settings.message_animation) {
+ life.dismissed = true;
+ return false;
+ },
+ .shown => {},
+ }
+ life.frame = 0;
+ if (p.settings.message_linger_ms > 0) {
+ life.phase = .lingering;
+ } else if (p.settings.message_animation) {
+ life.phase = .leaving;
+ } else return true;
+ return false;
+}
+
+/// One animation frame of every message line. Each one that moves owes
+/// a frame, the one that finishes included: a tick asks for none itself.
+pub fn advanceMessages(p: *Pardes) void {
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.msg_len > 0 and pane.msg_life.phase != .shown) {
+ p.needs_frame = true;
+ if (advanceLine(p, &pane.msg_life)) {
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ }
+ }
+ var i: usize = 0;
+ while (i < pane.msg_older_len) {
+ if (pane.msg_older[i].life.phase != .shown) p.needs_frame = true;
+ if (advanceLine(p, &pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
+ }
+ }
+}
+
+/// One frame of one line's life; true once it has left.
+fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
+ if (life.phase == .shown) return false;
+ life.frame +|= 1;
+ switch (life.phase) {
+ .shown => {},
+ .entering => if (life.frame >= messageFrames(p.settings.message_fall_ms)) {
+ life.* = .{ .phase = if (life.dismissed) .lingering else .shown };
+ },
+ .lingering => if (life.frame >= messageFrames(p.settings.message_linger_ms)) {
+ if (!p.settings.message_animation) return true;
+ life.* = .{ .phase = .leaving };
+ },
+ .leaving => if (life.frame >= messageFrames(p.settings.message_dissolve_ms)) return true,
+ }
+ return false;
+}
+
+/// Is any message line moving (and so owed ticks)?
+pub fn messagesAnimating(pane: *const Pane) bool {
+ if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true;
+ for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true;
+ return false;
+}
+
+/// How present a message is (1 = fully) and how far above its row it
+/// still is (rows): a fall that decelerates into place with its colour
+/// arriving ahead of it, and a dissolve.
+pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
+
+/// Animation frames a message timing lasts; at least one.
+pub fn messageFrames(ms: u16) u16 {
+ return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms));
+}
+
+pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
+ const fall_frames = messageFrames(p.settings.message_fall_ms);
+ const dissolve_frames = messageFrames(p.settings.message_dissolve_ms);
+ return switch (life.phase) {
+ .shown, .lingering => .{ .alpha = 1, .slide = 0 },
+ .entering => blk: {
+ const t = @as(f32, @floatFromInt(@min(life.frame, fall_frames))) / @as(f32, @floatFromInt(fall_frames));
+ // Out of the gate fast and easing into its row (a cubic
+ // ease-out), so it is there the moment it is said and the
+ // last frames only settle it. No overshoot: at a row's height
+ // and a dozen frames, a follow-through is a pixel or two that
+ // reads as a jitter, not a bounce.
+ const left = 1 - t;
+ // The colour leads the motion: solid by half way, so what
+ // lands is already legible rather than still fading up.
+ const faded = 1 - @min(1, 2 * t);
+ break :blk .{ .alpha = 1 - faded * faded, .slide = -(left * left * left) };
+ },
+ .leaving => blk: {
+ // Counted to its last frame rather than past it, so that frame
+ // shows it gone and it never blinks out from part way.
+ const last = @max(1, dissolve_frames - 1);
+ const t = @as(f32, @floatFromInt(@min(life.frame, last))) / @as(f32, @floatFromInt(last));
+ // An exit eases in: still readable for a beat, then away
+ // faster and faster. It stays where it is -- the lines
+ // stacked around it do not move either.
+ break :blk .{ .alpha = 1 - t * t, .slide = 0 };
+ },
+ };
+}
+
+/// What notice band `i` says. The grid pass and the band layers both
+/// read it here, so a terminal and a pixel shell show the same line.
+pub fn noticeText(p: *const Pardes, pane: *const Pane, i: usize, leader_buf: *[16]u8) []const u8 {
+ return switch (pane.notices.kinds[i]) {
+ .message => pane.msg[0..pane.msg_len],
+ .older => pane.msg_older[pane.notices.index[i]].slice(),
+ .leader => leaderText(p, leader_buf),
+ .prompt => pane.promptText() orelse "",
+ };
+}
+
+/// The life of the message line notice `i` shows, if it shows one.
+pub fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife {
+ return switch (pane.notices.kinds[i]) {
+ .message => pane.msg_life,
+ .older => pane.msg_older[pane.notices.index[i]].life,
+ .leader, .prompt => null,
+ };
+}
+
+pub fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*o, a, b| {
+ const af: f32 = @floatFromInt(a);
+ const bf: f32 = @floatFromInt(b);
+ o.* = @intFromFloat(@round(bf + (af - bf) * std.math.clamp(amount, 0, 1)));
+ }
+ return out;
+}
+
+fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
+ if (text.len == 0) return;
+ const serial: u32 = if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
+ const kept = text[0..@min(text.len, LoggedMessage.cap)];
+ // /log hears every one: a client that retried and failed the same way
+ // is waiting on that second line. Only the +Messages view collapses.
+ ctlfs.events.noteMessage(p, serial, kept);
+ if (p.messages_len > 0) {
+ const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log];
+ if (last.serial == serial and
+ std.mem.eql(u8, Message.body(last.slice()), Message.body(kept)))
+ {
+ // The NEWEST wording wins, so the row carries the latest clock
+ // rather than the moment the run started.
+ last.len = @intCast(kept.len);
+ @memcpy(last.text[0..last.len], kept);
+ last.repeats +|= 1;
+ return;
+ }
+ }
+ const slot = &p.messages[p.messages_head];
+ slot.len = @intCast(kept.len);
+ @memcpy(slot.text[0..slot.len], kept);
+ slot.serial = serial;
+ slot.repeats = 1;
+ p.messages_head = (p.messages_head + 1) % limits.message_log;
+ p.messages_len = @min(p.messages_len + 1, limits.message_log);
+}
+
+/// The log oldest-first, which is reading order.
+pub fn messageLog(p: *const Pardes, i: usize) ?*const LoggedMessage {
+ if (i >= p.messages_len) return null;
+ const first = (p.messages_head + limits.message_log - p.messages_len) % limits.message_log;
+ return &p.messages[(first + i) % limits.message_log];
+}
+
+pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
+ var buf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
+ setMessage(p, id, text);
+}
+
+/// The one place that decides which transient lines a pane is showing and
+/// in what order. Everything downstream reads the answer: the body layer
+/// so it does not paint under them, and the paint pass so it can stack
+/// them instead of letting the last one win the row.
+pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void {
+ pane.notices.len = 0;
+ if (r.w <= config.GUTTER or r.h <= BOX_H) return;
+ // Oldest message line on top, so lines already up do not move when a
+ // new one lands under them; when rows run out the oldest go first.
+ for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot);
+ if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot);
+ if (id == p.active and p.leader_on) pane.notices.push(.leader);
+ if (pane.promptText()) |text| if (text.len > 0) pane.notices.push(.prompt);
+ // A pane only has so many rows to give. When they do not all fit the
+ // LAST ones survive, because the prompt is last and it is the one
+ // carrying the cursor: a prompt you cannot see is a prompt you type
+ // into blind. A two-row pane spends its only body row on one notice,
+ // which is what the single shared row used to do anyway.
+ const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H));
+ const n = &pane.notices;
+ if (n.span() <= room) return;
+ // Too short for the gaps: close them up in screen order, and if even
+ // that does not fit, the top bands go.
+ var i: usize = 1;
+ while (i < n.len) : (i += 1) {
+ var j = i;
+ while (j > 0 and n.row[j - 1] > n.row[j]) : (j -= 1) {
+ std.mem.swap(Pane.Notices.Kind, &n.kinds[j - 1], &n.kinds[j]);
+ std.mem.swap(u8, &n.index[j - 1], &n.index[j]);
+ std.mem.swap(u8, &n.row[j - 1], &n.row[j]);
+ }
+ }
+ const drop = n.len -| room;
+ for (0..n.len - drop) |k| {
+ n.kinds[k] = n.kinds[k + drop];
+ n.index[k] = n.index[k + drop];
+ n.row[k] = @intCast(k);
+ }
+ n.len -= @intCast(drop);
+}
+
+test "the message log keeps what the row forgets, and collapses repeats" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+
+ setMessage(p, 0, "14:32:07 saved /x.zig");
+ setMessage(p, 0, "14:32:09 saved /x.zig"); // same event, later clock
+ setMessage(p, 0, "save: AccessDenied");
+
+ try std.testing.expectEqual(@as(usize, 2), p.messages_len);
+ const first = messageLog(p, 0).?;
+ try std.testing.expectEqual(@as(u16, 2), first.repeats);
+ // ...and the NEWEST wording is what survives, so the row carries the last
+ // time it happened rather than the first.
+ try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice());
+ try std.testing.expectEqualStrings("save: AccessDenied", messageLog(p, 1).?.slice());
+ try std.testing.expect(messageLog(p, 2) == null);
+
+ // The same text from a DIFFERENT pane is a different event: one pane's
+ // failure must not be recorded as another's.
+ p.newScratchBelow(0);
+ const other = p.active;
+ setMessage(p, other, "save: AccessDenied");
+ try std.testing.expectEqual(@as(usize, 3), p.messages_len);
+ try std.testing.expectEqual(p.panes[other].?.serial, messageLog(p, 2).?.serial);
+
+ // Progress is NOT logged: it arrives several times a second for a whole
+ // index and would push everything else out (`setStatus`).
+ setStatus(p, 0, "14:32:10 lsp rust-analyzer: Indexing 47%");
+ try std.testing.expectEqual(@as(usize, 3), p.messages_len);
+
+ // The ring wraps rather than grows, and still reads oldest-first.
+ for (0..limits.message_log + 5) |i| {
+ var buf: [32]u8 = undefined;
+ setMessage(p, 0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable);
+ }
+ try std.testing.expectEqual(@as(usize, limits.message_log), p.messages_len);
+ try std.testing.expectEqualStrings("line 5", messageLog(p, 0).?.slice());
+ var last_buf: [32]u8 = undefined;
+ const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable;
+ try std.testing.expectEqualStrings(want_last, messageLog(p, limits.message_log - 1).?.slice());
+}
+
+test "Msg writes the transient row by hand, bare or with text, and input ends it" {
+ const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const row = struct {
+ fn of(pp: *Pardes, i: usize) []const u8 {
+ const pane = pp.panes[i].?;
+ return pane.msg[0..pane.msg_len];
+ }
+ }.of;
+
+ // the whole tail, spaces and all: the row is a sentence, not a word
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts"));
+ try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2));
+ // ...on the pane that ran it, and nowhere else
+ try std.testing.expectEqualStrings("", row(p, 0));
+
+ // Bare, it reports ITSELF through reportError — the `<operation>: <Error>`
+ // every failed save and refused Look arrives in.
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
+ try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+
+ // Input starts its exit: it lingers, dissolves, and is gone.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+ for (0..200) |_| p.update(.tick);
+ try std.testing.expectEqualStrings("", row(p, 2));
+ // ...and with neither linger nor animation it is as old as your last input.
+ try std.testing.expect(p.executeBuiltinLine(2, "MessageAnimation off"));
+ try std.testing.expect(p.executeBuiltinLine(2, "MessageLinger 0"));
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg gone"));
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqualStrings("", row(p, 2));
+
+ // Longer than the row's buffer is CUT, never refused and never overrun:
+ // this word is the only way to reach that boundary without a 300-byte path.
+ var long: [600]u8 = @splat('x');
+ var line: [608]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long});
+ try std.testing.expect(p.executeBuiltinLine(2, cmd));
+ try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
+}
+
+test "a message falls in, lingers past the input that dismisses it, and dissolves" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.settings.message_animation);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ try std.testing.expect(p.animationActive());
+ // An ease-out fall: starts a whole row up and unseen, covers most of the
+ // way at once and settles into its row, landing exactly on it without
+ // passing it. Its colour is whole by half way.
+ const start = messageMotion(p, pane.msg_life);
+ try std.testing.expectEqual(@as(f32, -1), start.slide);
+ try std.testing.expectEqual(@as(f32, 0), start.alpha);
+ const fall = messageFrames(p.settings.message_fall_ms);
+ var last = start;
+ var first_step: f32 = 0;
+ var last_step: f32 = 0;
+ for (0..fall) |frame| {
+ p.needs_frame = false;
+ p.update(.tick);
+ // Every tick that moves it owes a frame, the landing one included.
+ try std.testing.expect(p.needs_frame);
+ const now = messageMotion(p, pane.msg_life);
+ try std.testing.expect(now.slide >= last.slide and now.slide <= 0);
+ try std.testing.expect(now.alpha >= last.alpha);
+ if (frame == 0) first_step = now.slide - last.slide;
+ // Never slower to colour up than to arrive, and solid by half way.
+ try std.testing.expect(now.alpha >= 1 + now.slide);
+ if (2 * (frame + 1) >= fall) try std.testing.expectEqual(@as(f32, 1), now.alpha);
+ last_step = now.slide - last.slide;
+ last = now;
+ }
+ try std.testing.expect(first_step > 4 * last_step);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(f32, 0), messageMotion(p, pane.msg_life).slide);
+ try std.testing.expect(!p.animationActive());
+ // Shown until input, like always; the same line again does not re-enter.
+ for (0..100) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ // Input (a key that announces nothing): 160 ms of lingering (ten frames), unaffected by more input...
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ for (0..9) |_| {
+ p.update(.tick);
+ p.update(.{ .key = .{ .cp = 'h' } });
+ }
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ p.update(.tick);
+ // ...then an ease-in dissolve: it lets go slowly and goes faster and
+ // faster, in place, its last frame showing it gone.
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ const dissolve = messageFrames(p.settings.message_dissolve_ms);
+ var alpha: f32 = 1;
+ var first_drop: f32 = 0;
+ var last_drop: f32 = 0;
+ for (0..dissolve) |frame| {
+ const now = messageMotion(p, pane.msg_life);
+ try std.testing.expect(now.alpha <= alpha);
+ try std.testing.expectEqual(@as(f32, 0), now.slide);
+ if (frame == 1) first_drop = alpha - now.alpha;
+ last_drop = alpha - now.alpha;
+ alpha = now.alpha;
+ p.needs_frame = false;
+ p.update(.tick);
+ }
+ try std.testing.expectEqual(@as(f32, 0), alpha);
+ try std.testing.expect(first_drop > 0 and first_drop < last_drop);
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+ try std.testing.expect(!p.animationActive());
+ // The tick that clears it still owes the frame that shows it gone.
+ try std.testing.expect(p.needs_frame);
+
+ // Dismissed mid-fall: it lands, then lingers.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ p.update(.tick);
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ for (0..messageFrames(p.settings.message_fall_ms)) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ // Shown again while lingering: fresh, so it stays; a status update to a
+ // line on screen swaps its text in place instead of falling again.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ setStatus(p, p.active, "progress 50%");
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqualStrings("progress 50%", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ // The fall and the dissolve are timings of their own, in milliseconds.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageFall 48"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 0"));
+ try std.testing.expectEqual(@as(u16, 3), messageFrames(p.settings.message_fall_ms));
+ try std.testing.expectEqual(@as(u16, 1), messageFrames(p.settings.message_dissolve_ms));
+ // The Config report reads back as configuration.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageAnimation"));
+ try std.testing.expect(!p.settings.message_animation);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg plain"));
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+}
+
+test "messages stack, oldest on top, each on its own timing" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
+ // `Verbose off` announced itself before it took effect.
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ const fall = messageFrames(p.settings.message_fall_ms);
+ setMessage(p, p.active, "first");
+ for (0..fall) |_| p.update(.tick);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ // A second message stacks under the first instead of replacing it.
+ setMessage(p, p.active, "second");
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_older_len);
+ try std.testing.expectEqualStrings("first", pane.msg_older[0].slice());
+ try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ collectNotices(p, pane, p.rects[p.active], p.active);
+ try std.testing.expectEqual(Pane.Notices.Kind.older, pane.notices.kinds[0]);
+ try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[1]);
+ // One key dismisses both, but each runs its own clock: the first starts
+ // lingering now, the second only once it has landed.
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_older[0].life.phase);
+ try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
+ const linger = messageFrames(p.settings.message_linger_ms);
+ const dissolve = messageFrames(p.settings.message_dissolve_ms);
+ for (0..linger + dissolve) |_| p.update(.tick);
+ // The first is gone; the second, a fall later, is still dissolving.
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ for (0..fall) |_| p.update(.tick);
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+ try std.testing.expect(!p.animationActive());
+ // A status ticking in place does not pile up.
+ setStatus(p, p.active, "lsp 10%");
+ setStatus(p, p.active, "lsp 20%");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ // Lines keep their rows: when the top one leaves, the one under it does
+ // not move up, and the next message fills the gap instead.
+ setMessage(p, p.active, "top");
+ setMessage(p, p.active, "under");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older[0].slot);
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_slot);
+ pane.removeOlderMessage(0);
+ collectNotices(p, pane, p.rects[p.active], p.active);
+ try std.testing.expectEqual(@as(u8, 1), pane.notices.row[0]);
+ try std.testing.expectEqual(@as(u8, 2), pane.notices.span());
+ setMessage(p, p.active, "fills");
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_slot);
+ try std.testing.expectEqual(@as(u8, 1), pane.msg_older[0].slot);
+ // The stack keeps the newest few.
+ for ([_][]const u8{ "a", "b", "c", "d", "e" }) |text| setMessage(p, p.active, text);
+ try std.testing.expectEqual(@as(u8, Pane.message_stack), pane.msg_older_len);
+ try std.testing.expectEqualStrings("b", pane.msg_older[0].slice());
+ try std.testing.expectEqualStrings("e", pane.msg[0..pane.msg_len]);
+}
+
+test "a builtin announces itself on the message row, and Verbose silences it" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[p.active].?;
+
+ p.sync();
+ try std.testing.expect(p.settings.verbose);
+ // Wrap, not Collapse: a builtin that folds its own pane away leaves no row
+ // for its announcement, which is correct and not what this is testing.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
+ try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]);
+
+ // The notice list is what the paint pass reads, so the announcement has to
+ // reach it, not just the buffer.
+ collectNotices(p, pane, p.rects[p.active], p.active);
+ 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]);
+
+ pane.msg_len = 0;
+ p.settings.verbose = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+}
+
+test "notices stack over the body's top rows without moving them" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ 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 top = p.bodyTop(r);
+
+ // A message and a leader prefix at once: two chips, in the order
+ // collectNotices chose, each one row tall and each one row further down.
+ try std.testing.expect(p.executeBuiltinLine(id, "Msg first"));
+ p.leader_on = true;
+ p.leader_n = 0;
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(u8, 2), pane.notices.len);
+ // An overlay, not a reservation: the body still starts where it did.
+ try std.testing.expectEqual(top, p.bodyTop(r));
+
+ var seen: usize = 0;
+ for (p.surface.tagLayers()) |*layer| {
+ if (layer.cols == 0 or layer.kind != .notice) continue;
+ try std.testing.expectEqual(@as(u16, 1), layer.viewport.h);
+ // Sized to its own message, right up against the pane's right edge --
+ // not the width of the pane.
+ try std.testing.expectEqual(r.x + r.w, layer.viewport.x + layer.viewport.w);
+ try std.testing.expect(layer.viewport.w < r.w - config.GUTTER);
+ try std.testing.expect(layer.viewport.y == top or layer.viewport.y == top + 1);
+ try std.testing.expectEqual(pane.notices.left[layer.viewport.y - top], layer.viewport.x);
+ seen += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 2), seen);
+ // "first" is longer than "SPC", so its chip is wider.
+ try std.testing.expect(pane.notices.left[0] < pane.notices.left[1]);
+
+ // Gone with the notices: no stale chip left behind.
+ p.leader_on = false;
+ pane.msg_len = 0;
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ for (p.surface.tagLayers()) |*layer|
+ try std.testing.expect(layer.cols == 0 or layer.kind != .notice);
+}
diff --git a/src/Output.zig b/src/Output.zig
index 00122b16..3b8100ac 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -3,6 +3,7 @@
const panes = @import("panes.zig");
const std = @import("std");
const pardes = @import("pardes.zig");
+const Messages = @import("Messages.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
const modal = @import("modal.zig");
@@ -684,7 +685,7 @@ pub fn openMessages(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
var i: usize = 0;
- while (p.messageLog(i)) |m| : (i += 1) {
+ while (Messages.messageLog(p, i)) |m| : (i += 1) {
if (m.serial != 0) try out.writer.print("{d}: ", .{m.serial});
try out.writer.writeAll(m.slice());
if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats});
diff --git a/src/detached/server.zig b/src/detached/server.zig
index c8d4b45e..e33354cf 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -15,7 +15,7 @@ const ninep_io = @import("../9p_io.zig");
const look = @import("../look.zig");
-const message = pardes.Pardes.Message;
+const message = pardes.Messages.Message;
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 2d1c0d25..5d998470 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -15,7 +15,7 @@ const linux = std.os.linux;
const pardes = @import("pardes.zig");
const colors = @import("colors.zig");
const filesystem = @import("fs.zig");
-const message = pardes.Pardes.Message;
+const message = pardes.Messages.Message;
pub const Identity = struct {
inode: std.Io.File.INode,
diff --git a/src/fs.zig b/src/fs.zig
index 86154fc1..5817b3a4 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -38,6 +38,7 @@ const source_files = [_]Source{
.{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") },
.{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") },
.{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") },
+ .{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 6174c0d8..86c69ff6 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -8,7 +8,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 = pardes.Pardes.Message;
+const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
const deck = @import("deck.zig");
const crt = @import("crt.zig");
diff --git a/src/macos.zig b/src/macos.zig
index b4e03c13..cab8212a 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -8,7 +8,7 @@ const libc = std.c;
const pardes = @import("pardes.zig");
const colors = @import("colors.zig");
const look = @import("look.zig");
-const message = pardes.Pardes.Message;
+const message = pardes.Messages.Message;
const layout = @import("layout.zig");
const file_watch = @import("file_watch.zig");
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 8d5ba16c..0e8d7bb7 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -149,7 +149,7 @@ pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
- var buf: [pardes.LoggedMessage.cap + 32]u8 = undefined;
+ var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
pushLog(p, (if (serial == 0)
std.fmt.bufPrint(&buf, "msg - {s}\n", .{text})
else
diff --git a/src/pardes.zig b/src/pardes.zig
index 6fefe081..c171c669 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+pub const Messages = @import("Messages.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
pub const themes = colors.themes;
@@ -430,6 +431,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("Messages.zig");
_ = @import("selection_pipe.zig");
_ = @import("colors.zig");
_ = @import("surface.zig");
@@ -637,49 +639,6 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines"
try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
}
-test "the message log keeps what the row forgets, and collapses repeats" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
- defer p.deinit();
-
- p.setMessage(0, "14:32:07 saved /x.zig");
- p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock
- p.setMessage(0, "save: AccessDenied");
-
- try std.testing.expectEqual(@as(usize, 2), p.messages_len);
- const first = p.messageLog(0).?;
- try std.testing.expectEqual(@as(u16, 2), first.repeats);
- // ...and the NEWEST wording is what survives, so the row carries the last
- // time it happened rather than the first.
- try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice());
- try std.testing.expectEqualStrings("save: AccessDenied", p.messageLog(1).?.slice());
- try std.testing.expect(p.messageLog(2) == null);
-
- // The same text from a DIFFERENT pane is a different event: one pane's
- // failure must not be recorded as another's.
- p.newScratchBelow(0);
- const other = p.active;
- p.setMessage(other, "save: AccessDenied");
- try std.testing.expectEqual(@as(usize, 3), p.messages_len);
- try std.testing.expectEqual(p.panes[other].?.serial, p.messageLog(2).?.serial);
-
- // Progress is NOT logged: it arrives several times a second for a whole
- // index and would push everything else out (`setStatus`).
- p.setStatus(0, "14:32:10 lsp rust-analyzer: Indexing 47%");
- try std.testing.expectEqual(@as(usize, 3), p.messages_len);
-
- // The ring wraps rather than grows, and still reads oldest-first.
- for (0..limits.message_log + 5) |i| {
- var buf: [32]u8 = undefined;
- p.setMessage(0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable);
- }
- try std.testing.expectEqual(@as(usize, limits.message_log), p.messages_len);
- try std.testing.expectEqualStrings("line 5", p.messageLog(0).?.slice());
- var last_buf: [32]u8 = undefined;
- const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable;
- try std.testing.expectEqualStrings(want_last, p.messageLog(limits.message_log - 1).?.slice());
-}
-
test "the acme chords act once per selection, not once on the primary" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -2332,216 +2291,6 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu
try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null);
}
-test "Msg writes the transient row by hand, bare or with text, and input ends it" {
- const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const row = struct {
- fn of(pp: *Pardes, i: usize) []const u8 {
- const pane = pp.panes[i].?;
- return pane.msg[0..pane.msg_len];
- }
- }.of;
-
- // the whole tail, spaces and all: the row is a sentence, not a word
- try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts"));
- try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2));
- // ...on the pane that ran it, and nowhere else
- try std.testing.expectEqualStrings("", row(p, 0));
-
- // Bare, it reports ITSELF through reportError — the `<operation>: <Error>`
- // every failed save and refused Look arrives in.
- try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
- try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
-
- // Input starts its exit: it lingers, dissolves, and is gone.
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
- for (0..200) |_| p.update(.tick);
- try std.testing.expectEqualStrings("", row(p, 2));
- // ...and with neither linger nor animation it is as old as your last input.
- try std.testing.expect(p.executeBuiltinLine(2, "MessageAnimation off"));
- try std.testing.expect(p.executeBuiltinLine(2, "MessageLinger 0"));
- try std.testing.expect(p.executeBuiltinLine(2, "Msg gone"));
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqualStrings("", row(p, 2));
-
- // Longer than the row's buffer is CUT, never refused and never overrun:
- // this word is the only way to reach that boundary without a 300-byte path.
- var long: [600]u8 = @splat('x');
- var line: [608]u8 = undefined;
- const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long});
- try std.testing.expect(p.executeBuiltinLine(2, cmd));
- try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
-}
-
-test "a message falls in, lingers past the input that dismisses it, and dissolves" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[p.active].?;
- try std.testing.expect(p.settings.message_animation);
- try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
- try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
- try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
- try std.testing.expect(p.animationActive());
- // An ease-out fall: starts a whole row up and unseen, covers most of the
- // way at once and settles into its row, landing exactly on it without
- // passing it. Its colour is whole by half way.
- const start = p.messageMotion(pane.msg_life);
- try std.testing.expectEqual(@as(f32, -1), start.slide);
- try std.testing.expectEqual(@as(f32, 0), start.alpha);
- const fall = Pardes.messageFrames(p.settings.message_fall_ms);
- var last = start;
- var first_step: f32 = 0;
- var last_step: f32 = 0;
- for (0..fall) |frame| {
- p.needs_frame = false;
- p.update(.tick);
- // Every tick that moves it owes a frame, the landing one included.
- try std.testing.expect(p.needs_frame);
- const now = p.messageMotion(pane.msg_life);
- try std.testing.expect(now.slide >= last.slide and now.slide <= 0);
- try std.testing.expect(now.alpha >= last.alpha);
- if (frame == 0) first_step = now.slide - last.slide;
- // Never slower to colour up than to arrive, and solid by half way.
- try std.testing.expect(now.alpha >= 1 + now.slide);
- if (2 * (frame + 1) >= fall) try std.testing.expectEqual(@as(f32, 1), now.alpha);
- last_step = now.slide - last.slide;
- last = now;
- }
- try std.testing.expect(first_step > 4 * last_step);
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- try std.testing.expectEqual(@as(f32, 0), p.messageMotion(pane.msg_life).slide);
- try std.testing.expect(!p.animationActive());
- // Shown until input, like always; the same line again does not re-enter.
- for (0..100) |_| p.update(.tick);
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- // Input (a key that announces nothing): 160 ms of lingering (ten frames), unaffected by more input...
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
- for (0..9) |_| {
- p.update(.tick);
- p.update(.{ .key = .{ .cp = 'h' } });
- }
- try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
- p.update(.tick);
- // ...then an ease-in dissolve: it lets go slowly and goes faster and
- // faster, in place, its last frame showing it gone.
- try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
- const dissolve = Pardes.messageFrames(p.settings.message_dissolve_ms);
- var alpha: f32 = 1;
- var first_drop: f32 = 0;
- var last_drop: f32 = 0;
- for (0..dissolve) |frame| {
- const now = p.messageMotion(pane.msg_life);
- try std.testing.expect(now.alpha <= alpha);
- try std.testing.expectEqual(@as(f32, 0), now.slide);
- if (frame == 1) first_drop = alpha - now.alpha;
- last_drop = alpha - now.alpha;
- alpha = now.alpha;
- p.needs_frame = false;
- p.update(.tick);
- }
- try std.testing.expectEqual(@as(f32, 0), alpha);
- try std.testing.expect(first_drop > 0 and first_drop < last_drop);
- try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
- try std.testing.expect(!p.animationActive());
- // The tick that clears it still owes the frame that shows it gone.
- try std.testing.expect(p.needs_frame);
-
- // Dismissed mid-fall: it lands, then lingers.
- try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
- try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
- p.update(.tick);
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
- for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| p.update(.tick);
- try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
- // Shown again while lingering: fresh, so it stays; a status update to a
- // line on screen swaps its text in place instead of falling again.
- try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- p.setStatus(p.active, "progress 50%");
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- try std.testing.expectEqualStrings("progress 50%", pane.msg[0..pane.msg_len]);
- try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
- // The fall and the dissolve are timings of their own, in milliseconds.
- try std.testing.expect(p.executeBuiltinLine(p.active, "MessageFall 48"));
- try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 0"));
- try std.testing.expectEqual(@as(u16, 3), Pardes.messageFrames(p.settings.message_fall_ms));
- try std.testing.expectEqual(@as(u16, 1), Pardes.messageFrames(p.settings.message_dissolve_ms));
- // The Config report reads back as configuration.
- try std.testing.expect(p.executeBuiltinLine(p.active, "MessageAnimation"));
- try std.testing.expect(!p.settings.message_animation);
- try std.testing.expect(p.executeBuiltinLine(p.active, "Msg plain"));
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
-}
-
-test "messages stack, oldest on top, each on its own timing" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[p.active].?;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
- try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
- // `Verbose off` announced itself before it took effect.
- pane.msg_len = 0;
- pane.msg_life = .{};
- const fall = Pardes.messageFrames(p.settings.message_fall_ms);
- p.setMessage(p.active, "first");
- for (0..fall) |_| p.update(.tick);
- try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
- // A second message stacks under the first instead of replacing it.
- p.setMessage(p.active, "second");
- try std.testing.expectEqual(@as(u8, 1), pane.msg_older_len);
- try std.testing.expectEqualStrings("first", pane.msg_older[0].slice());
- try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
- try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
- p.collectNotices(pane, p.rects[p.active], p.active);
- try std.testing.expectEqual(Pane.Notices.Kind.older, pane.notices.kinds[0]);
- try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[1]);
- // One key dismisses both, but each runs its own clock: the first starts
- // lingering now, the second only once it has landed.
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_older[0].life.phase);
- try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
- const linger = Pardes.messageFrames(p.settings.message_linger_ms);
- const dissolve = Pardes.messageFrames(p.settings.message_dissolve_ms);
- for (0..linger + dissolve) |_| p.update(.tick);
- // The first is gone; the second, a fall later, is still dissolving.
- try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
- try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
- try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
- for (0..fall) |_| p.update(.tick);
- try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
- try std.testing.expect(!p.animationActive());
- // A status ticking in place does not pile up.
- p.setStatus(p.active, "lsp 10%");
- p.setStatus(p.active, "lsp 20%");
- try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
- // Lines keep their rows: when the top one leaves, the one under it does
- // not move up, and the next message fills the gap instead.
- p.setMessage(p.active, "top");
- p.setMessage(p.active, "under");
- try std.testing.expectEqual(@as(u8, 0), pane.msg_older[0].slot);
- try std.testing.expectEqual(@as(u8, 1), pane.msg_slot);
- pane.removeOlderMessage(0);
- p.collectNotices(pane, p.rects[p.active], p.active);
- try std.testing.expectEqual(@as(u8, 1), pane.notices.row[0]);
- try std.testing.expectEqual(@as(u8, 2), pane.notices.span());
- p.setMessage(p.active, "fills");
- try std.testing.expectEqual(@as(u8, 0), pane.msg_slot);
- try std.testing.expectEqual(@as(u8, 1), pane.msg_older[0].slot);
- // The stack keeps the newest few.
- for ([_][]const u8{ "a", "b", "c", "d", "e" }) |text| p.setMessage(p.active, text);
- try std.testing.expectEqual(@as(u8, Pane.message_stack), pane.msg_older_len);
- try std.testing.expectEqualStrings("b", pane.msg_older[0].slice());
- try std.testing.expectEqualStrings("e", pane.msg[0..pane.msg_len]);
-}
-
test "Font keeps requested and acknowledged faces as separate plain state" {
if (comptime !font_picker) return; // there is no Font builtin on tty or web
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
@@ -4851,23 +4600,6 @@ pub const Options = struct {
rows: u16 = 24,
};
-pub const LoggedMessage = struct {
- pub const cap = 256;
- text: [cap]u8 = undefined,
- len: u16 = 0,
- /// The serial of the pane that said it, or 0 for none in particular. A
- /// serial, not a slot: slots are reused, so an old line would name
- /// whichever pane took the slot since.
- serial: u32 = 0,
- /// How many times in a row. Saturating: a key held down against the same
- /// refusal is one event, not four hundred.
- repeats: u16 = 0,
-
- pub fn slice(m: *const LoggedMessage) []const u8 {
- return m.text[0..m.len];
- }
-};
-
pub const Haptic = enum { none, exec, look };
/// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads
@@ -5035,7 +4767,7 @@ pub const Pardes = struct {
fallback: host_io.Fallback,
/// The message-row log: a fixed ring, never allocated, never grown. See
/// `logMessage` and the `Messages` builtin.
- messages: [limits.message_log]LoggedMessage = @splat(.{}),
+ messages: [limits.message_log]Messages.LoggedMessage = @splat(.{}),
/// Next slot to write. `messages_len` saturates at the ring's size.
messages_head: usize = 0,
messages_len: usize = 0,
@@ -5067,6 +4799,12 @@ pub const Pardes = struct {
/// for — see panes.Terminal.RowsCache for the lifetime rule
shell_rows: panes.Terminal.RowsCache = .{},
+ /// The message row's entry points, called from everywhere as
+ /// `p.setMessage(...)`; they live with the rest of it in Messages.zig.
+ pub const setStatus = Messages.setStatus;
+ pub const setMessage = Messages.setMessage;
+ pub const reportError = Messages.reportError;
+
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
const image_gpa = opts.image_allocator orelse gpa;
const pdf_gpa = opts.pdf_allocator orelse gpa;
@@ -5177,60 +4915,6 @@ pub const Pardes = struct {
return p;
}
- /// `SPC` plus the chord keys typed so far, spaced out the way the help
- /// index spells a leader path.
- fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 {
- buf.* = @splat(' ');
- @memcpy(buf[0..3], "SPC");
- var at: usize = 3;
- for (p.leader_keys[0..p.leader_n]) |ch| {
- if (at + 2 > buf.len) break;
- buf[at + 1] = ch;
- at += 2;
- }
- return buf[0..at];
- }
-
- /// How wide a notice chip is, in grid columns: its own text plus a blank
- /// cell either side. A notice is an OVERLAY over the body's top rows, not
- /// a row taken from them, so it is sized to its message and never to the
- /// pane. Counted in GRID columns rather than scaled into the tagline face
- /// it is drawn in, because the canonical grid is what a terminal client
- /// draws and a chip narrower than its own text there would clip it; the
- /// tagline face simply leaves a little more room inside the chip.
- fn noticeCols(p: *const Pardes, text: []const u8, limit: u16) u16 {
- _ = p;
- if (limit == 0) return 0;
- return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit)));
- }
-
- const Printed = struct { left: u16, dropped: usize };
-
- /// Prints `text` flush with the right edge of the band, and answers the
- /// column it started at so a cursor can follow it. Text wider than the
- /// band loses its HEAD: the end is the part that says something -- a
- /// path's file name, an error's reason -- and `dropped` is how many
- /// columns of it went.
- fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) Printed {
- const width = panes.File.displayWidth(text);
- if (width <= w) {
- const left: u16 = @intCast(x + w - width);
- _ = s.print(left, row, @intCast(width), text, style);
- return .{ .left = left, .dropped = 0 };
- }
- // Whole glyphs only: a cut inside a wide one would leave the rest a
- // column too wide and clip its LAST glyph instead of the first.
- var dropped = width - w;
- var start = panes.File.rawAtDisplay(text, dropped);
- if (panes.File.rawDisplayCol(text, start) < dropped) {
- dropped += 1;
- start = panes.File.rawAtDisplay(text, dropped);
- }
- const left: u16 = @intCast(x + w - (width - dropped));
- _ = s.print(left, row, @intCast(width - dropped), text[start..], style);
- return .{ .left = left, .dropped = dropped };
- }
-
/// The layout every single-pane boot starts from.
fn oneColumn(p: *Pardes) void {
p.ncol = 1;
@@ -5654,297 +5338,6 @@ pub const Pardes = struct {
p.font_request_taken = false;
}
- pub const Message = struct {
- const libc = std.c;
- 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;
-
- pub fn body(text: []const u8) []const u8 {
- if (text.len < 10) return text;
- if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text;
- for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| {
- if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text;
- }
- return text[10..];
- }
-
- 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) {
- _ = 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];
- }
- };
-
- /// A status updates the message line in place (a progress count does not
- /// pile up); a message is an event, and stacks.
- pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void {
- p.showMessage(id, text, false);
- }
-
- pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void {
- p.logMessage(id, text);
- p.showMessage(id, text, true);
- }
-
- fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
- if (id >= MAX_PANES) return;
- const pane = p.panes[id] orelse return;
- // One row of printable text: a language server's multi-line report
- // reads as one line, and the cut never leaves half a glyph behind.
- var n: usize = @min(text.len, pane.msg.len);
- if (n < text.len) {
- while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
- }
- const same = pane.msg_len == n and for (text[0..n], pane.msg[0..n]) |c, have| {
- if ((if (c < 0x20 or c == 0x7f) ' ' else c) != have) break false;
- } else true;
- // A new event moves the line on screen up the stack, where it keeps
- // its own timing, and falls in below it.
- // Only an event stacks on an event: an announcement or a status
- // stands in for whatever comes next.
- if (stack and pane.msg_len > 0 and !same and !pane.msg_announcement and !pane.msg_status)
- pane.pushOlderMessage();
- if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot();
- pane.msg_announcement = false;
- pane.msg_status = !stack;
- const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving;
- for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c;
- pane.msg_len = @intCast(n);
- p.needs_frame = true;
- if (arrives) {
- pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown };
- } else if (pane.msg_life.phase == .lingering or
- (pane.msg_life.phase == .entering and !p.settings.message_animation))
- {
- // The same line again, or a status update: fresh, so it stays.
- pane.msg_life = .{};
- } else {
- pane.msg_life.dismissed = false;
- }
- }
-
- /// The key or click that used to clear the message row now starts each
- /// line's exit: it stays `message_linger_ms`, then dissolves. A line
- /// already on its way out keeps its own timing.
- fn dismissMessage(p: *Pardes, pane: *Pane) void {
- if (pane.msg_len > 0 and p.dismissLine(&pane.msg_life)) {
- pane.msg_len = 0;
- pane.msg_life = .{};
- }
- var i: usize = 0;
- while (i < pane.msg_older_len) {
- if (p.dismissLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
- }
- }
-
- /// Start one line's exit; true when it is gone at once (no linger, no
- /// animation).
- fn dismissLine(p: *const Pardes, life: *Pane.MessageLife) bool {
- switch (life.phase) {
- .lingering, .leaving => return false,
- // A falling line lands first, then lingers -- unless falling was
- // just turned off, and nothing will land it.
- .entering => if (p.settings.message_animation) {
- life.dismissed = true;
- return false;
- },
- .shown => {},
- }
- life.frame = 0;
- if (p.settings.message_linger_ms > 0) {
- life.phase = .lingering;
- } else if (p.settings.message_animation) {
- life.phase = .leaving;
- } else return true;
- return false;
- }
-
- /// One animation frame of every message line. Each one that moves owes
- /// a frame, the one that finishes included: a tick asks for none itself.
- fn advanceMessages(p: *Pardes) void {
- for (p.panes) |slot| {
- const pane = slot orelse continue;
- if (pane.msg_len > 0 and pane.msg_life.phase != .shown) {
- p.needs_frame = true;
- if (p.advanceLine(&pane.msg_life)) {
- pane.msg_len = 0;
- pane.msg_life = .{};
- }
- }
- var i: usize = 0;
- while (i < pane.msg_older_len) {
- if (pane.msg_older[i].life.phase != .shown) p.needs_frame = true;
- if (p.advanceLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
- }
- }
- }
-
- /// One frame of one line's life; true once it has left.
- fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
- if (life.phase == .shown) return false;
- life.frame +|= 1;
- switch (life.phase) {
- .shown => {},
- .entering => if (life.frame >= messageFrames(p.settings.message_fall_ms)) {
- life.* = .{ .phase = if (life.dismissed) .lingering else .shown };
- },
- .lingering => if (life.frame >= messageFrames(p.settings.message_linger_ms)) {
- if (!p.settings.message_animation) return true;
- life.* = .{ .phase = .leaving };
- },
- .leaving => if (life.frame >= messageFrames(p.settings.message_dissolve_ms)) return true,
- }
- return false;
- }
-
- /// Is any message line moving (and so owed ticks)?
- fn messagesAnimating(pane: *const Pane) bool {
- if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true;
- for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true;
- return false;
- }
-
- /// How present a message is (1 = fully) and how far above its row it
- /// still is (rows): a fall that decelerates into place with its colour
- /// arriving ahead of it, and a dissolve.
- const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
-
- /// Animation frames a message timing lasts; at least one.
- pub fn messageFrames(ms: u16) u16 {
- return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms));
- }
-
- fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
- const fall_frames = messageFrames(p.settings.message_fall_ms);
- const dissolve_frames = messageFrames(p.settings.message_dissolve_ms);
- return switch (life.phase) {
- .shown, .lingering => .{ .alpha = 1, .slide = 0 },
- .entering => blk: {
- const t = @as(f32, @floatFromInt(@min(life.frame, fall_frames))) / @as(f32, @floatFromInt(fall_frames));
- // Out of the gate fast and easing into its row (a cubic
- // ease-out), so it is there the moment it is said and the
- // last frames only settle it. No overshoot: at a row's height
- // and a dozen frames, a follow-through is a pixel or two that
- // reads as a jitter, not a bounce.
- const left = 1 - t;
- // The colour leads the motion: solid by half way, so what
- // lands is already legible rather than still fading up.
- const faded = 1 - @min(1, 2 * t);
- break :blk .{ .alpha = 1 - faded * faded, .slide = -(left * left * left) };
- },
- .leaving => blk: {
- // Counted to its last frame rather than past it, so that frame
- // shows it gone and it never blinks out from part way.
- const last = @max(1, dissolve_frames - 1);
- const t = @as(f32, @floatFromInt(@min(life.frame, last))) / @as(f32, @floatFromInt(last));
- // An exit eases in: still readable for a beat, then away
- // faster and faster. It stays where it is -- the lines
- // stacked around it do not move either.
- break :blk .{ .alpha = 1 - t * t, .slide = 0 };
- },
- };
- }
-
- /// What notice band `i` says. The grid pass and the band layers both
- /// read it here, so a terminal and a pixel shell show the same line.
- fn noticeText(p: *const Pardes, pane: *const Pane, i: usize, leader_buf: *[16]u8) []const u8 {
- return switch (pane.notices.kinds[i]) {
- .message => pane.msg[0..pane.msg_len],
- .older => pane.msg_older[pane.notices.index[i]].slice(),
- .leader => p.leaderText(leader_buf),
- .prompt => pane.promptText() orelse "",
- };
- }
-
- /// The life of the message line notice `i` shows, if it shows one.
- fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife {
- return switch (pane.notices.kinds[i]) {
- .message => pane.msg_life,
- .older => pane.msg_older[pane.notices.index[i]].life,
- .leader, .prompt => null,
- };
- }
-
- fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 {
- var out: [3]u8 = undefined;
- for (&out, from, to) |*o, a, b| {
- const af: f32 = @floatFromInt(a);
- const bf: f32 = @floatFromInt(b);
- o.* = @intFromFloat(@round(bf + (af - bf) * std.math.clamp(amount, 0, 1)));
- }
- return out;
- }
-
- fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
- if (text.len == 0) return;
- const serial: u32 = if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
- const kept = text[0..@min(text.len, LoggedMessage.cap)];
- // /log hears every one: a client that retried and failed the same way
- // is waiting on that second line. Only the +Messages view collapses.
- ctlfs.events.noteMessage(p, serial, kept);
- if (p.messages_len > 0) {
- const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log];
- if (last.serial == serial and
- std.mem.eql(u8, Message.body(last.slice()), Message.body(kept)))
- {
- // The NEWEST wording wins, so the row carries the latest clock
- // rather than the moment the run started.
- last.len = @intCast(kept.len);
- @memcpy(last.text[0..last.len], kept);
- last.repeats +|= 1;
- return;
- }
- }
- const slot = &p.messages[p.messages_head];
- slot.len = @intCast(kept.len);
- @memcpy(slot.text[0..slot.len], kept);
- slot.serial = serial;
- slot.repeats = 1;
- p.messages_head = (p.messages_head + 1) % limits.message_log;
- p.messages_len = @min(p.messages_len + 1, limits.message_log);
- }
-
- /// The log oldest-first, which is reading order.
- pub fn messageLog(p: *const Pardes, i: usize) ?*const LoggedMessage {
- if (i >= p.messages_len) return null;
- const first = (p.messages_head + limits.message_log - p.messages_len) % limits.message_log;
- return &p.messages[(first + i) % limits.message_log];
- }
- pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
- var buf: [256]u8 = undefined;
- const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
- p.setMessage(id, text);
- }
-
fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool {
if (id >= MAX_PANES) return false;
const pane = p.panes[id] orelse return false;
@@ -6268,11 +5661,11 @@ pub const Pardes = struct {
switch (ev) {
.key, .mouse => {
for (p.panes) |slot| {
- if (slot) |pane| p.dismissMessage(pane);
+ if (slot) |pane| Messages.dismissMessage(p, pane);
}
p.clip_pending = null;
},
- .tick => p.advanceMessages(),
+ .tick => Messages.advanceMessages(p),
else => {},
}
if (p.fs.listeners != 0) p.fs.origin = switch (ev) {
@@ -12199,7 +11592,7 @@ pub const Pardes = struct {
pub fn animationActive(p: *const Pardes) bool {
if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true;
for (p.panes) |slot| if (slot) |pane| {
- if (messagesAnimating(pane)) return true;
+ if (Messages.messagesAnimating(pane)) return true;
};
const scene = p.settings.scene_effects;
if (scene.crt or scene.ripple or scene.glitch) return true;
@@ -12305,7 +11698,7 @@ pub const Pardes = struct {
pane.body_visible_rows = 0;
pane.context_row_limit = null;
s.body_layers[id].rows = 0;
- p.collectNotices(pane, p.rects[id], id);
+ Messages.collectNotices(p, pane, p.rects[id], id);
try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
try p.renderBodyLayer(arena, pane, p.rects[id], id);
}
@@ -12338,21 +11731,21 @@ pub const Pardes = struct {
// draws, and a band it cannot see is a message it never gets.
// The GUI skips grid cells a tag layer covers, so nothing is
// drawn twice there.
- const text = p.noticeText(pane, i, &leader_buf);
- const chip = p.noticeCols(text, tw);
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ const chip = Messages.noticeCols(p, text, tw);
if (chip == 0) continue;
const cx = tx + tw - chip;
pane.notices.left[i] = cx;
// A message coming or going blends toward the page it sits
// on; a grid cannot move it by less than a row.
var style = msg_style;
- if (noticeLife(pane, i)) |life| {
+ if (Messages.noticeLife(pane, i)) |life| {
// No page colour to fade to (a terminal's own default):
// the text dissolves into its chip instead.
- const alpha = p.messageMotion(life).alpha;
+ const alpha = Messages.messageMotion(p, life).alpha;
const page = th.bg orelse chrome.tag_bg;
- style.fg = .{ .rgb = blendRgb(chrome.tag_fg, page, alpha) };
- style.bg = .{ .rgb = blendRgb(chrome.tag_bg, page, alpha) };
+ style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) };
+ style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
}
s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
// One column short of the chip's right edge: a prompt's caret
@@ -12360,7 +11753,7 @@ pub const Pardes = struct {
// right would put it past the pane, off the grid, and past what
// the detached wire will encode -- which drops every frame for
// as long as the prompt is up.
- const printed = printRight(s, cx, row, chip -| 1, text, style);
+ const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
if (kind != .prompt or id != p.active) continue;
const at = pane.promptAt() orelse continue;
const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
@@ -12867,7 +12260,7 @@ pub const Pardes = struct {
const first = p.bodyTop(r);
var leader_buf: [16]u8 = undefined;
for (pane.notices.slice(), 0..) |kind, i| {
- const text = p.noticeText(pane, i, &leader_buf);
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
if (text.len == 0) continue;
const cx = pane.notices.left[i];
if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
@@ -12889,7 +12282,7 @@ pub const Pardes = struct {
@memcpy(line[pad..], kept);
// A pixel shell slides the message in as it fades up, and
// fades it on the way out.
- const motion: MessageMotion = if (noticeLife(pane, i)) |life| p.messageMotion(life) else .{};
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
const fade = motion.alpha;
const page = p.theme().bg orelse chrome.tag_bg;
const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
@@ -12898,7 +12291,7 @@ pub const Pardes = struct {
.y = first + @as(u16, pane.notices.row[i]),
.w = chip,
.h = BOX_H,
- }, line, blendRgb(chrome.tag_bg, page, fade), blendRgb(chrome.tag_fg, page, fade));
+ }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade));
p.surface.tag_layers[layer_index].slide = motion.slide;
p.surface.tag_layers[layer_index].fade = fade;
// The caret rides the band, where the prompt's glyphs are,
@@ -12980,47 +12373,6 @@ pub const Pardes = struct {
}
}
- /// The one place that decides which transient lines a pane is showing and
- /// in what order. Everything downstream reads the answer: the body layer
- /// so it does not paint under them, and the paint pass so it can stack
- /// 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;
- if (r.w <= config.GUTTER or r.h <= BOX_H) return;
- // Oldest message line on top, so lines already up do not move when a
- // new one lands under them; when rows run out the oldest go first.
- for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot);
- if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot);
- if (id == p.active and p.leader_on) pane.notices.push(.leader);
- if (pane.promptText()) |text| if (text.len > 0) pane.notices.push(.prompt);
- // A pane only has so many rows to give. When they do not all fit the
- // LAST ones survive, because the prompt is last and it is the one
- // carrying the cursor: a prompt you cannot see is a prompt you type
- // into blind. A two-row pane spends its only body row on one notice,
- // which is what the single shared row used to do anyway.
- const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H));
- const n = &pane.notices;
- if (n.span() <= room) return;
- // Too short for the gaps: close them up in screen order, and if even
- // that does not fit, the top bands go.
- var i: usize = 1;
- while (i < n.len) : (i += 1) {
- var j = i;
- while (j > 0 and n.row[j - 1] > n.row[j]) : (j -= 1) {
- std.mem.swap(Pane.Notices.Kind, &n.kinds[j - 1], &n.kinds[j]);
- std.mem.swap(u8, &n.index[j - 1], &n.index[j]);
- std.mem.swap(u8, &n.row[j - 1], &n.row[j]);
- }
- }
- const drop = n.len -| room;
- for (0..n.len - drop) |k| {
- n.kinds[k] = n.kinds[k + drop];
- n.index[k] = n.index[k + drop];
- n.row[k] = @intCast(k);
- }
- n.len -= @intCast(drop);
- }
-
fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void {
if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return;
if (r.w <= config.GUTTER or r.h <= BOX_H) return;
@@ -13683,102 +13035,6 @@ test "Esc back into a tty leaves its view at the prompt" {
try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset);
}
-test "a builtin announces itself on the message row, and Verbose silences it" {
- if (platform == .web) return;
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- const pane = p.panes[p.active].?;
-
- p.sync();
- try std.testing.expect(p.settings.verbose);
- // Wrap, not Collapse: a builtin that folds its own pane away leaves no row
- // for its announcement, which is correct and not what this is testing.
- try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
- try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]);
-
- // The notice list is what the paint pass reads, so the announcement has to
- // reach it, not just the buffer.
- p.collectNotices(pane, p.rects[p.active], p.active);
- 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]);
-
- pane.msg_len = 0;
- p.settings.verbose = false;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
- try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
-}
-
-test "notices stack over the body's top rows without moving them" {
- if (platform == .web) return;
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- 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 top = p.bodyTop(r);
-
- // A message and a leader prefix at once: two chips, in the order
- // collectNotices chose, each one row tall and each one row further down.
- try std.testing.expect(p.executeBuiltinLine(id, "Msg first"));
- p.leader_on = true;
- p.leader_n = 0;
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(u8, 2), pane.notices.len);
- // An overlay, not a reservation: the body still starts where it did.
- try std.testing.expectEqual(top, p.bodyTop(r));
-
- var seen: usize = 0;
- for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != .notice) continue;
- try std.testing.expectEqual(@as(u16, 1), layer.viewport.h);
- // Sized to its own message, right up against the pane's right edge --
- // not the width of the pane.
- try std.testing.expectEqual(r.x + r.w, layer.viewport.x + layer.viewport.w);
- try std.testing.expect(layer.viewport.w < r.w - config.GUTTER);
- try std.testing.expect(layer.viewport.y == top or layer.viewport.y == top + 1);
- try std.testing.expectEqual(pane.notices.left[layer.viewport.y - top], layer.viewport.x);
- seen += 1;
- }
- try std.testing.expectEqual(@as(usize, 2), seen);
- // "first" is longer than "SPC", so its chip is wider.
- try std.testing.expect(pane.notices.left[0] < pane.notices.left[1]);
-
- // Gone with the notices: no stale chip left behind.
- p.leader_on = false;
- pane.msg_len = 0;
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- for (p.surface.tagLayers()) |*layer|
- try std.testing.expect(layer.cols == 0 or layer.kind != .notice);
-}
-
test "a prompt chip is a band with its caret, still on the grid, and only the chip swallows a click" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 751bc42e..89a476f2 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -7,7 +7,7 @@ const vaxis = @import("vaxis");
const pardes = @import("../pardes.zig");
const tracy = @import("../tracy.zig");
const look = @import("../look.zig");
-const message = pardes.Pardes.Message;
+const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
const selection_pipe = @import("../selection_pipe.zig");
const ninep_io = @import("../9p_io.zig");