summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig433
1 files changed, 412 insertions, 21 deletions
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 —