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