diff options
Diffstat (limited to 'src/Messages.zig')
| -rw-r--r-- | src/Messages.zig | 773 |
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); +} |
