//! 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 animation = @import("animation.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; const Messages = @This(); /// The message-row log: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. log: [limits.message_log]LoggedMessage = @splat(.{}), /// Next slot to write. `len` saturates at the ring's size. head: usize = 0, len: usize = 0, 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_sent = 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); pane.msg_said_ms = nowMs(); 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; } } fn nowMs() i64 { if (comptime !pardes.hosted) return 0; var ts: std.c.timespec = undefined; if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); } /// With no frontend attached no input dismisses a message, and a detached /// session's /screen and messages kept what was said an hour ago: each /// pane's lines go once its newest has been up `message_linger_ms`, on the /// clock, checked as the core is stepped and by the detached session's loop /// between steps -- not as a 9P request is served, since a read is answered /// while a step is out and must change nothing. /// ponytail: a pane's older lines go with its newest, not each on its own /// clock; the render pipeline's clock will give every line its own. pub fn expireUnattended(p: *Pardes) void { if (!p.unattended) return; const now = nowMs(); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.msg_len == 0 and pane.msg_older_len == 0) continue; if (now - pane.msg_said_ms < p.settings.message_linger_ms) continue; pane.msg_len = 0; pane.msg_life = .{}; pane.msg_older_len = 0; p.needs_frame = true; } } /// 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; } /// Move every lingering message line `frames` along its linger at once: /// how `advance` jumps a wait instead of stepping it frame by frame. pub fn holdMessages(p: *Pardes, frames: u64) void { const n: u16 = @intCast(@min(frames, std.math.maxInt(u16))); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.msg_len > 0 and pane.msg_life.phase == .lingering) pane.msg_life.frame +|= n; for (pane.msg_older[0..pane.msg_older_len]) |*line| { if (line.life.phase == .lingering) line.life.frame +|= n; } } } /// Frames until this pane's message lines next change: 1 while a line /// falls in or dissolves, the rest of its linger while one only waits, null /// when every line just shows. pub fn messageWake(p: *const Pardes, pane: *const Pane) ?u64 { var soonest: ?u64 = null; const linger = messageFrames(p.settings.message_linger_ms); for (0..@as(usize, pane.msg_older_len) + 1) |i| { const life = if (i < pane.msg_older_len) pane.msg_older[i].life else if (pane.msg_len > 0) pane.msg_life else continue; const frames: u64 = switch (life.phase) { .shown => continue, .entering, .leaving => 1, .lingering => @max(1, linger -| life.frame), }; soonest = if (soonest) |n| @min(n, frames) else frames; } return soonest; } /// 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) + animation.frame_ms - 1) / 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. // The drop follows the Motion flavour (its wind-up, overshoot // and all) over the fall's length. const left = 1 - animation.Motion.of(p.settings.motion).ease(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); // And never behind it, however fast the flavour leaves. break :blk .{ .alpha = @max(1 - faded * faded, std.math.clamp(1 - left, 0, 1)), .slide = -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 (p.fs.session_write) 0 else 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.log[(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.log[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(m: *const Messages, i: usize) ?*const LoggedMessage { if (i >= m.len) return null; const first = (m.head + limits.message_log - m.len) % limits.message_log; return &m.log[(first + i) % limits.message_log]; } /// `: `: FileNotFound reads "file not found". /// What a route that would open a pane says when every slot is taken. /// "no space": 9ns's word for ENOSPC (it has none for ENFILE). pub const no_pane_slots = "no space for a pane: {d} max"; pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; var w = std.Io.Writer.fixed(&buf); w.print("{s}: ", .{operation}) catch {}; // Every route that opens a pane says the cap the same way. if (err == error.NoPaneSlots) { w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}; // A 9P look or exec that would open the pane fails with it too. p.fs.no_pane_slot = true; @memcpy(p.fs.ename[0..w.buffered().len], w.buffered()); p.fs.no_pane_slot_len = @intCast(w.buffered().len); return reportFailure(p, id, w.buffered()); } for (@errorName(err), 0..) |c, i| { if (std.ascii.isUpper(c) and i > 0) w.writeByte(' ') catch break; w.writeByte(std.ascii.toLower(c)) catch break; } reportFailure(p, id, w.buffered()); } /// The longest start of `text` that fits `max` bytes and ends between /// words (or, with no space to cut at, between characters). pub fn clip(text: []const u8, max: usize) []const u8 { if (text.len <= max) return text; if (std.mem.lastIndexOfScalar(u8, text[0 .. max + 1], ' ')) |sp| return text[0..sp]; var n = max; while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1; return text[0..n]; } /// reportError with the words already chosen. pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { // A builtin a ctl write runs: its first error is also the write's, cut // between words. if (p.fs.no_prompt and p.fs.failure_len == 0) { const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text; @memcpy(p.fs.failure[0..kept.len], kept); var n = kept.len; if (kept.len < text.len) { @memcpy(p.fs.failure[n..][0..3], "..."); n += 3; } p.fs.failure_len = @intCast(n); } setMessage(p, id, text); } test "an error reads as words and a failure is cut between them" { try std.testing.expectEqualStrings("go", clip("go haskell", 4)); try std.testing.expectEqualStrings("go haskell", clip("go haskell", 10)); try std.testing.expectEqualStrings("\u{e9}", clip("\u{e9}\u{e9}", 3)); } /// 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 <= pane.tag_rows) 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 - pane.tag_rows)); 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 = p.messages.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.messages.messageLog(1).?.slice()); try std.testing.expect(p.messages.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; setMessage(p, other, "save: AccessDenied"); try std.testing.expectEqual(@as(usize, 3), p.messages.len); try std.testing.expectEqual(p.panes[other].?.serial, p.messages.messageLog(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", p.messages.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.messages.messageLog(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 `: ` // every failed save and refused Look arrives in. try std.testing.expect(p.executeBuiltinLine(2, "Msg")); try std.testing.expectEqualStrings("Msg: no message", row(p, 2)); // Input starts its exit: it lingers, dissolves, and is gone. p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqualStrings("Msg: no message", 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 "with no frontend attached a message goes once it has lingered, on the clock" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = p.panes[p.active].?; p.setMessage(p.active, "said"); p.setMessage(p.active, "said again"); // Attached: it waits for input, however long. pane.msg_said_ms -= 60_000; p.update(.tick); try std.testing.expect(pane.msg_len > 0); // Detached with nobody attached: past its linger it is gone, and the // line above it with it. p.unattended = true; // A read served meanwhile changes nothing; the session's loop expires. _ = p.serveFs(.{ .tag = 1, .op = .read, .node = @intFromEnum(ctlfs.TopFile.index), .size = 64 }); try std.testing.expect(pane.msg_len > 0); expireUnattended(p); try std.testing.expectEqual(@as(u16, 0), pane.msg_len); try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len); // A fresh one stays until its linger is up. p.setMessage(p.active, "fresh"); p.update(.tick); try std.testing.expect(pane.msg_len > 0); } 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); // Crisp: the flavour whose drop leaves at full speed and lands without // passing its row (the others are the Motion tests'). p.settings.motion = .crisp; 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]); // A value it does not take is said, in place of the announcement. const wrapped = p.settings.wrap; try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap maybe")); try std.testing.expectEqualStrings("Wrap: takes on or off", pane.msg[0..pane.msg_len]); try std.testing.expectEqual(wrapped, p.settings.wrap); // 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.rows != 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(pane, 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(pane, r)); var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { if (layer.rows == 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.rows == 0 or layer.kind != .notice); } test "an idle core wants no wake and a lingering message sleeps to its linger's end" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); while (p.nextEffect()) |_| {} const frame = animation.frame_ns; p.advance(1_000_000); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); const pane = p.panes[p.active].?; try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); // Falling in: a frame from now. try std.testing.expectEqual(@as(?u64, p.stepped_ns + frame), p.nextWake()); const fall = messageFrames(p.settings.message_fall_ms); p.advance(p.stepped_ns + fall * frame); try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); // A key starts the 800 ms linger: the core sleeps to its end, not a // frame at a time, and the frames it jumps draw nothing. p.update(.{ .key = .{ .cp = 'x' } }); try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); const linger = messageFrames(p.settings.message_linger_ms); const due = p.stepped_ns + linger * frame; try std.testing.expectEqual(@as(?u64, due), p.nextWake()); p.needs_frame = false; p.advance(due - frame); try std.testing.expectEqual(@as(?u64, due), p.nextWake()); try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); p.advance(due); try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); try std.testing.expectEqual(@as(?u64, due + frame), p.nextWake()); p.advance(due + 10 * std.time.ns_per_s); try std.testing.expectEqual(@as(usize, 0), pane.msg_len); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); // A long linger ends when it says, jumped in one step, not stretched by // the cap on catching up with moving frames. try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 5000")); try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again")); p.advance(p.stepped_ns + fall * frame); p.update(.{ .key = .{ .cp = 'x' } }); const long_due = p.stepped_ns + messageFrames(5000) * frame; try std.testing.expectEqual(@as(?u64, long_due), p.nextWake()); p.advance(long_due); try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); }