From 40197552bed794b92410bdf803532162c8f5e27d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 10:46:33 -0300 Subject: Ease a message into its row fast first, its colour ahead of it, and out slow first A message fell in on t*t, an ease-in: it hung near its start for the first frames and was still moving at its fastest when it hit the row, a sixth of a row in its last frame. It now decelerates into place with an ease-out cubic, slide = -(1-t)^3: a quarter of the way in the first frame, and the last frames only settle it. There is no overshoot. At a row's height and the dozen frames of the 180 ms fall, a follow-through is a pixel or two, which reads as a jitter rather than a bounce. MessageFall keeps its 180 ms: the ease-out puts the chip where it is going within about 100 ms. The colour leads the motion: alpha = 1 - (1 - min(1, 2t))^2, whole by half way, so what lands is already legible. The SDL shell used to show the chip solid while it slid and fade it only on the way out; it now fades it up with the same alpha, and the rule under a chip carries the chip's fade (a new TagLayer.fade) instead of standing solid while the chip comes or goes. The dissolve was an ease-out, (1-t)^2: it lost a tenth of its colour in the first frame and spent its last frames nearly invisible. An exit eases in instead, alpha = 1 - t^2 over the same 300 ms: it lets go gently and leaves faster and faster. t is now counted to the last frame, so that frame shows it gone rather than it blinking out from 0.3%. 1 - t^3 was the other candidate; in captures it held the chip nearly untouched for half the dissolve after the linger had already held it, then dropped a sixth of its colour a frame, which reads as a pop. It stays in place: the lines stacked around it do not move, and a drift would read as it leaving its row. The test pins the shapes: a fast first step into the row, never past it, solid by half way; a dissolve whose first drop is smaller than its last and which ends at exactly nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/gui.zig | 5 +++- src/pardes.zig | 77 +++++++++++++++++++++++++++++++++++++++++-------------- src/tag_layer.zig | 4 +++ 3 files changed, 66 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index acb8d729..dd74ae58 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7311,7 +7311,10 @@ fn appendPaneChrome( // comes with it, and shows only once it is below that cut. const rule_y = top + (1 + layer.slide) * layout.h; if (rule_y - 1 < top) continue; - builder.addRect(x0, rule_y - 1, rule_x1, rule_y, overlayRgb(chrome.border)); + // ...and fades up and dissolves with it rather than standing alone. + var rule = overlayRgb(chrome.border); + rule.a = layer.fade; + builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule); } appendColumnSpines(core, surface.rows, layout, builder); } diff --git a/src/pardes.zig b/src/pardes.zig index 42729d50..4335ffde 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2746,19 +2746,32 @@ test "a message falls in, lingers past the input that dismisses it, and dissolve 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. + // 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); - var last = start.slide; - for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| { + 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); - last = now.slide; - } + 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()); @@ -2776,16 +2789,25 @@ test "a message falls in, lingers past the input that dismisses it, and dissolve } try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); p.update(.tick); - // ...then an ease-out dissolve toward nothing, and gone. + // ...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; - 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; + 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. @@ -7335,7 +7357,8 @@ pub const Pardes = struct { } /// 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. + /// 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. @@ -7350,12 +7373,26 @@ pub const Pardes = struct { .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 }; + // 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: { - const t = @as(f32, @floatFromInt(@min(life.frame, dissolve_frames))) / @as(f32, @floatFromInt(dissolve_frames)); - break :blk .{ .alpha = (1 - t) * (1 - t), .slide = 0 }; + // 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 }; }, }; } @@ -14585,10 +14622,10 @@ pub const Pardes = struct { const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - // A pixel shell slides the message in whole and only fades - // it on the way out. + // 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 fade = if (motion.slide == 0) motion.alpha else 1; + const fade = motion.alpha; 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), .{ @@ -14598,6 +14635,7 @@ pub const Pardes = struct { .h = BOX_H, }, line, blendRgb(chrome.tag_bg, page, fade), 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, // at the same place in the text the grid pass puts it. if (kind != .prompt or id != p.active) continue; @@ -14634,6 +14672,7 @@ pub const Pardes = struct { layer.bg = bg; layer.cursor = null; layer.slide = 0; + layer.fade = 1; 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 diff --git a/src/tag_layer.zig b/src/tag_layer.zig index f8cc6eca..52da297d 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -30,6 +30,10 @@ pub const Layer = struct { /// that is moving into place. A pixel shell draws it offset and clipped /// to the rows below the viewport's top; the grid has no such thing. slide: f32 = 0, + /// How present a notice band is (1 = fully) while it fades up or + /// dissolves. `bg` and the cells are already blended by it; a pixel + /// shell uses it for what it draws around the band, like its rule. + fade: f32 = 1, pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); -- cgit v1.3