summaryrefslogtreecommitdiff
path: root/src/Messages.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/Messages.zig')
-rw-r--r--src/Messages.zig773
1 files changed, 773 insertions, 0 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);
+}