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