From 9695cb098c784792af79c22d46a17f7bc7247d33 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 22 Sep 2026 17:43:57 -0300 Subject: Repaint PDF highlights by row, send rasters by shared memory, and animate messages PDF highlights (hover preview, search, selection) are baked into page rasters, and any change re-rendered the whole page with MuPDF; the TTY then re-sent it as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion over a PDF invalidated the page even when no preview was shown, so every motion paid that. Now: - A raster whose baked highlight set equals the wanted one is left alone. - A highlighted page keeps its clean rows (before highlights and tint); a change repaints only the rows of quads that differ, running MuPDF's highlight pass (pardes_pdf_paint_highlights) over those clean rows and tinting them: the operations a full render performs, so the pixels are identical. MuPDF band renders are NOT bit-identical to a whole page (edge rows, resampled images), so they are never used to patch; the comment claiming otherwise is corrected. - ImagePlace.patch hands shells the changed rows; the GUI uploads just those rows into the texture it holds. - The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and sends rasters as a /dev/shm name when the terminal reads it; direct base64 otherwise (ssh). - Shells that take row patches (GUI, TTY with shm) repaint a selection while it is dragged instead of only on release. Latency elsewhere: - TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts into the input queue, so input inside the frame is handled at once. - TTY and GUI: queued pointer motions coalesce to the last. - GUI: a skipped swapchain image re-arms the frame (3 retries); animations still tick while nothing presents. - Editing: the line index is carried across an edit instead of rebuilt from a scan of the whole file per keystroke. Messages fall into their row (ease-in; the GUI slides the band out from under the tagline, a terminal fades it), stay until the next input as before, linger MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation toggles it; both are settings, in Config and startup files. The snapshot harness pins the old behaviour. The detached server now ticks animations. A restored terminal comes back live: the old screen and scrollback (dumped as clean VT by ghostty's formatter, replayed at the new size; older dumps fall back to their rendered text), a dim "restored history" marker, then a new shell in the directory it was in. Right-click on a line number in a file pane looks at that line (a sticky context header's number included). Measured with an external pty driver (TTY), an in-process fence trace (GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity against the baseline and a whole-page oracle); balanced A/A/B rounds, paired per-round statistics. Messages stack: each event gets its own row and its own fall, linger and dissolve; a line keeps its row until it leaves and a new one fills the first free row. Announcements and statuses are replaced in place, not stacked. MessageFall, MessageDissolve and DumpDir are settings Config reports. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/pardes.zig | 433 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 412 insertions(+), 21 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index cc57127f..fae14f47 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2722,8 +2722,16 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it try std.testing.expect(p.executeBuiltinLine(2, "Msg")); try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); - // A message is exactly as old as your last input. + // 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: @@ -2735,6 +2743,151 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it 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-in fall: starts a whole row up, lands exactly on it. + const start = p.messageMotion(pane.msg_life); + try std.testing.expectEqual(@as(f32, -1), start.slide); + var last = start.slide; + for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| { + 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); + last = now.slide; + } + 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-out dissolve toward nothing, and gone. + try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); + var alpha: f32 = 1; + for (0..Pardes.messageFrames(p.settings.message_dissolve_ms)) |_| { + const now = p.messageMotion(pane.msg_life).alpha; + try std.testing.expect(now <= alpha); + alpha = now; + p.needs_frame = false; + p.update(.tick); + } + 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 }); @@ -3774,6 +3927,8 @@ pub const NativePlacement = if (pdf_enabled) struct { paper_bg: ?[3]u8 = null, } else struct {}; +pub const ImagePatch = struct { from: u32, y: u32, h: u32 }; + pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or @@ -3787,6 +3942,10 @@ pub const ImagePlace = struct { rgba: []const u8, iw: usize, ih: usize, + /// Rows [y, y+h) of `rgba` are the only ones that differ from the + /// same pane page at revision `from`: a shell still holding that one may + /// update just these rows instead of taking the whole image again. + patch: ?ImagePatch = null, pub fn cacheKey(place: ImagePlace) ImageCacheKey { if (comptime pdf_enabled) @@ -6194,6 +6353,10 @@ pub const Pardes = struct { chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), animate_theme_changes: bool = false, native_images: bool = false, + /// The shell can take a changed image a few rows at a time (a texture row + /// upload, a shared-memory hand-off), so a PDF selection is repainted as + /// it is dragged instead of once on release. + native_image_patches: bool = false, quit: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, @@ -6250,6 +6413,9 @@ pub const Pardes = struct { /// Is there a frame worth drawing? Cleared once one is presented, set by /// anything that can change what is on screen. The first frame always is. needs_frame: bool = true, + /// Set by a shell whose present was skipped (no swapchain image): the + /// frame is still owed, and the next pump draws it again. + present_skipped: bool = false, /// Builtins announce themselves on the message row. Off while startup /// configuration is applied: those lines are nobody clicking, and should /// not leave the row holding the name of the last one. @@ -7040,16 +7206,18 @@ pub const Pardes = struct { } }; + /// 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); + p.showMessage(id, text, false); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { p.logMessage(id, text); - p.showMessage(id, text); + p.showMessage(id, text, true); } - fn showMessage(p: *Pardes, id: usize, text: []const u8) void { + 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 @@ -7058,8 +7226,158 @@ pub const Pardes = struct { 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), eased: an ease-in fall, an ease-out 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)); + const eased = t * t; + break :blk .{ .alpha = eased, .slide = eased - 1 }; + }, + .leaving => blk: { + const t = @as(f32, @floatFromInt(@min(life.frame, dissolve_frames))) / @as(f32, @floatFromInt(dissolve_frames)); + break :blk .{ .alpha = (1 - t) * (1 - t), .slide = 0 }; + }, + }; + } + + /// 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 { @@ -7358,8 +7676,11 @@ pub const Pardes = struct { _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); + // Settled before post_present: a shell ticks there, and what that + // tick moves owes the next frame. + p.needs_frame = p.present_skipped; + p.present_skipped = false; if (v.post_present) |f| f(h.ctx); - p.needs_frame = false; } /// One request of the control filesystem, from whichever thread has the @@ -7389,6 +7710,10 @@ pub const Pardes = struct { // the screen. p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); + // An announcement stands in only until its own step says more. + for (p.panes) |slot| if (slot) |pane| { + pane.msg_announcement = false; + }; switch (ev) { .tick => {}, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { @@ -7415,10 +7740,11 @@ pub const Pardes = struct { switch (ev) { .key, .mouse => { for (p.panes) |slot| { - if (slot) |pane| pane.msg_len = 0; + if (slot) |pane| p.dismissMessage(pane); } p.clip_pending = null; }, + .tick => p.advanceMessages(), else => {}, } if (p.fs.listeners != 0) p.fs.origin = switch (ev) { @@ -11462,7 +11788,7 @@ pub const Pardes = struct { // A notice chip is chrome: it sits OVER the body's top rows, so a // click inside one belongs to nobody. The rest of those rows is // ordinary body text and still answers normally. - if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.len and + if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and p.panes[id].?.notices.covers(row - body_y, col)) return null; if (!on_tag) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null; @@ -12252,6 +12578,21 @@ pub const Pardes = struct { } } else { const clk = pane.sel[b]; + // A right-click on a line NUMBER looks at that line of + // this file -- a sticky context header's number included, + // since the row map holds each header's own source line. + if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and + clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty) + if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) { + const line = pane.wrapAt(clk.r0 - BOX_H).line; + if (line >= 0) { + pane.sel[b].state = .none; + var buf: [48]u8 = undefined; + const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return; + p.runBuiltin(config.look_cmd, s.id, "", target); + return; + } + }; var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded @@ -12876,8 +13217,10 @@ pub const Pardes = struct { // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin // then says for itself replaces the announcement. - if (p.announce and p.settings.verbose and builtins.registry.announces(b)) + if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { p.setMessage(id, @tagName(b)); + pane.msg_announcement = true; + } const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } @@ -13121,10 +13464,16 @@ pub const Pardes = struct { const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; - const restored = try panes.Terminal.restore(p, src); + // A host that runs shells gets them back: the old history + // above a new shell in the directory it was in. The web + // shell and an isolated build only ever replay it. + const live = comptime (terminal_panes and platform != .web and !isolated); + const revive = live and t.cwd.len <= effect_path_cap; + const restored = try panes.Terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; + if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } }); break :terminal restored; }, .file => try panes.File.restore(p, i, src), @@ -13487,6 +13836,9 @@ pub const Pardes = struct { /// core work belongs in this one predicate so TTY and GUI mature it alike. 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; + }; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; return p.presentation.animating(); @@ -13653,7 +14005,7 @@ pub const Pardes = struct { const first = p.bodyTop(r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { - const row = first + @as(u16, @intCast(i)); + const row = first + @as(u16, pane.notices.row[i]); // Every notice is painted on the canonical grid as well as // emitted as a tag layer: the grid is what a terminal client // draws, and a band it cannot see is a message it never gets. @@ -13661,6 +14013,7 @@ pub const Pardes = struct { // drawn twice there. const text = switch (kind) { .message => pane.msg[0..pane.msg_len], + .older => pane.msg_older[pane.notices.index[i]].slice(), .leader => p.leaderText(&leader_buf), .prompt => blk: { const at = pane.promptAt() orelse continue; @@ -13671,13 +14024,24 @@ pub const Pardes = struct { if (chip == 0) continue; const cx = tx + tw - chip; pane.notices.left[i] = cx; - s.fill(cx, row, chip, 1, .{ .bg = msg_style.bg, .font_role = .tagline }); + // 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| { + // 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 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) }; + } + 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 // sits AFTER its last character the moment it opens, and flush // 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, msg_style); + const printed = 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; @@ -14166,6 +14530,7 @@ pub const Pardes = struct { for (pane.notices.slice(), 0..) |kind, i| { const text = switch (kind) { .message => pane.msg[0..pane.msg_len], + .older => pane.msg_older[pane.notices.index[i]].slice(), .leader => p.leaderText(&leader_buf), // A prompt is an editing line with a cursor in it, so it // stays on the grid where the cursor can sit on a cell. @@ -14190,12 +14555,19 @@ pub const Pardes = struct { const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{ + // A pixel shell slides the message in whole and only fades + // it on the way out. + const motion: MessageMotion = if (noticeLife(pane, i)) |life| p.messageMotion(life) else .{}; + const fade = if (motion.slide == 0) motion.alpha else 1; + const page = p.theme().bg orelse chrome.tag_bg; + const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), .{ .x = cx, - .y = first + @as(u16, @intCast(i)), + .y = first + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H, - }, line, chrome.tag_bg, chrome.tag_fg); + }, line, blendRgb(chrome.tag_bg, page, fade), blendRgb(chrome.tag_fg, page, fade)); + p.surface.tag_layers[layer_index].slide = motion.slide; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { @@ -14221,6 +14593,7 @@ pub const Pardes = struct { layer.cols = columns; layer.bg = bg; layer.cursor = null; + layer.slide = 0; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select @@ -14270,7 +14643,10 @@ pub const Pardes = struct { 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; - if (pane.msg_len > 0) pane.notices.push(.message); + // 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.promptAt()) |at| { if (pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0) pane.notices.push(.prompt); @@ -14281,11 +14657,26 @@ pub const Pardes = struct { // 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)); - if (pane.notices.len > room) { - const drop = pane.notices.len - room; - for (0..room) |i| pane.notices.kinds[i] = pane.notices.kinds[i + drop]; - pane.notices.len = room; + 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 { @@ -14390,7 +14781,7 @@ pub const Pardes = struct { // Below the notice chips: a picture is drawn after the cells (the // GUI's image pass, kitty's z-order), so a chip over it would be // painted out. Text gets the overlay; a picture gives up the rows. - const shown = pane.notices.len; + const shown = pane.notices.span(); const image_h = (r.h -| BOX_H) -| shown; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — -- cgit v1.3