diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 790 |
1 files changed, 23 insertions, 767 deletions
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 }); |
