diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 20:31:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0 (patch) | |
| tree | 6cd19cfb7fbd076433fbb965e8a52ac1800949fb | |
| parent | 242467eb9f066f80164d534be55c2c4ebc9b5604 (diff) | |
| download | pardes-c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0.tar.gz pardes-c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0.zip | |
Motion flavours differ by design, and drive notice drops and pane moves
The flavours looked alike because Lift, the only motion that read them,
moves a shadow a few pixels: overshoot and wind-up were sub-pixel. By design
now: crisp leaves at full speed and never passes its mark; smooth (the
default) eases in and out in twice crisp's time, a slide arriving in 208 ms
(inside §8.1's 220); bouncy passes its mark by ~30% and settles twice;
playful winds up, passes by ~40%, and stretches and squashes, text included
(the user's choice). Small motions are exaggerated by the flavour's gain.
Motion gains launch, gain, span and norm, and ease(u)/speed(u) for moves of
fixed length. The notice drop follows ease() over its fall (alpha never
behind its position) and stays inside its pane's body past its row. Panel
tracks carry the flavour in Track.motion (the old padding byte; macOS header
name follows); opening and moving slides, zooms and rises follow it over
span x their frames; landed, a pane is exactly its target; closing keeps
its own exit. A pane past its mark never takes a neighbour's click; a
rising one stays in its box.
Tests: each flavour's lift trajectory distinct; flavour character; exact
landing bit for bit; a stretched pane's drawn cells map back; the overshoot
hit rule; the vertical clip at peak; legacy tests pinned to crisp. GUI
golden 18-mid-transition changes (the retuned smooth slide at frame 6).
Shared files: src/animation.zig, src/Presentation.zig, src/Messages.zig,
src/macos.zig, src/config.zig, src/pardes.zig tests, src/ninep/screen.zig
test, docs/effects.md, docs/config.md.
| -rw-r--r-- | docs/config.md | 2 | ||||
| -rw-r--r-- | docs/effects.md | 37 | ||||
| -rw-r--r-- | src/Messages.zig | 10 | ||||
| -rw-r--r-- | src/Presentation.zig | 59 | ||||
| -rw-r--r-- | src/animation.zig | 147 | ||||
| -rw-r--r-- | src/config.zig | 2 | ||||
| -rw-r--r-- | src/draw.zig | 48 | ||||
| -rw-r--r-- | src/gui/gui.zig | 37 | ||||
| -rw-r--r-- | src/macos.zig | 4 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 6 | ||||
| -rw-r--r-- | test/gui-goldens.txt | 2 |
12 files changed, 316 insertions, 40 deletions
diff --git a/docs/config.md b/docs/config.md index 324b54e9..921f817e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -537,7 +537,7 @@ Lift rim a hairline just above the focused tag (light, or shade on a lig Lift auto a shadow on a light page; on a dark one the others recede Lift off InactiveDim 30 the unfocused panes' text fades 30% toward its ground -Motion smooth off, crisp, smooth, bouncy or playful +Motion smooth off, crisp, smooth (the default), bouncy or playful GripWidth 150 the grip's button and the scrollbar under it, percent of the theme's rail_px (12px at a 17px tagline; 50 to 300) ``` diff --git a/docs/effects.md b/docs/effects.md index eefa52d3..3f375b7d 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -26,18 +26,31 @@ why. The verdict is the user's. ## Motion flavours -`Motion off|crisp|smooth|bouncy|playful` (default smooth) is one parameter set +`Motion off|crisp|smooth|bouncy|playful` (default smooth, tuned so arrivals +land inside §8.1's 220 ms; bouncy and playful run longer, opt-in) is one parameter set (animation.Motion) every fx animation reads, so a flavour is data, not a branch in each effect. Input is never blocked, and a new target always retargets from the current position and velocity. -| flavour | timing (ω) | follow-through (ζ) | anticipation | squash/stretch | secondary lag | arcs | -|---|---|---|---|---|---|---| -| off | instant | — | — | — | — | — | -| crisp | 44 | 1 (none) | — | — | in step | — | -| smooth | 26 | 1 (none) | — | — | 0.85 | 0.04 | -| bouncy | 30 | 0.55 (overshoots ~12%) | — | 0.12 | 0.8 | 0.08 | -| playful | 24 | 0.42 (overshoots ~23%) | 0.08 of the move | 0.25 | 0.7 | 0.15 | +| flavour | timing (ω, length) | follow-through (ζ) | anticipation | squash/stretch | exaggeration (gain) | secondary lag | arcs | +|---|---|---|---|---|---|---|---| +| off | instant | — | — | — | — | — | — | +| crisp | 60, 0.6× (leaves at full speed) | 1 (none) | — | — | 1 | in step | — | +| smooth | 34, 1.1× (slow in, slow out; ≤220 ms) | 1 (none) | — | — | 1 | 0.85 | 0.04 | +| bouncy | 24, 1.4× | 0.35 (overshoots ~30%, settles twice) | — | 0.15 | 1.25 | 0.8 | 0.08 | +| playful | 18, 1.7× | 0.28 (overshoots ~40%) | 0.15 of the move | 0.35 | 1.4 | 0.55 | 0.15 | + +The flavours are distinct by design, not tuning: crisp leaves at full speed +and never passes its mark; smooth takes twice as long and eases in and out; bouncy clearly passes its mark and settles back once or twice; +playful winds up the other way first, flies well past, and stretches along +its path and squashes as it lands, its text with it (the user's choice); a +pointer inverts the same stretched box, and landed, a pane is exactly its +target. A motion only a few pixels big (Lift) is +exaggerated by the flavour's gain so its character shows. The flavours drive +the Lift spring (time-based), the notice drop and the pane moves (PanelSlide, +PanelZoom, PanelVertical opening or moving, whose length scales with the +flavour); a closing pane keeps its effect's own exit. Chart and side-by-side +video: .scratch/render/motion/compare/. Which principles each motion uses: @@ -45,8 +58,12 @@ Which principles each motion uses: (bouncy and playful lift past full and settle back), anticipation (playful dips before it rises; below zero nothing is drawn, so it reads as a beat before the lift), staging (only the focused pane lifts; nothing else moves - with a focus change). Squash, stretch, arcs and secondary lag have nothing - to act on in a lift. + with a focus change), exaggeration (the flavour's gain). Squash, stretch, + arcs and secondary lag have nothing to act on in a lift. +- **Notice drop and pane moves**: timing and follow-through from the flavour's + spring over the move's length; anticipation (playful). A pane past its mark + never takes a neighbour's click, a rising one stays in its box, and a notice + past its row stays in its pane's body. - **Cursor** (G3, next): designed around the same set: glide on the spring, stretch along the path, an arc on long jumps, the trailing corners as the secondary action. diff --git a/src/Messages.zig b/src/Messages.zig index ea62041f..800218b7 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -356,11 +356,14 @@ pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { // 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 drop follows the Motion flavour (its wind-up, overshoot + // and all) over the fall's length. + const left = 1 - animation.Motion.of(p.settings.motion).ease(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) }; + // And never behind it, however fast the flavour leaves. + break :blk .{ .alpha = @max(1 - faded * faded, std.math.clamp(1 - left, 0, 1)), .slide = -left }; }, .leaving => blk: { // Counted to its last frame rather than past it, so that frame @@ -644,6 +647,9 @@ test "a message falls in, lingers past the input that dismisses it, and dissolve while (p.nextEffect()) |_| {} const pane = p.panes[p.active].?; try std.testing.expect(p.settings.message_animation); + // Crisp: the flavour whose drop leaves at full speed and lands without + // passing its row (the others are the Motion tests'). + p.settings.motion = .crisp; 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); diff --git a/src/Presentation.zig b/src/Presentation.zig index 3b5bb3e0..15ed9ea6 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -183,7 +183,7 @@ pub fn sync(self: *Presentation, p: *Pardes) void { prior.?.from else old.box; - const next: Track = .{ + const next = stamp(p, .{ .serial = pane.serial, .pane = @intCast(id), .phase = if (!same_lifetime or @@ -191,19 +191,19 @@ pub fn sync(self: *Presentation, p: *Pardes) void { .effect = effect, .from = from, .to = target, - }; + }); self.tracks[id] = next; changed = true; animated_change = true; } else { - const next: Track = .{ + const next = stamp(p, .{ .serial = pane.serial, .pane = @intCast(id), .phase = .opening, .effect = effect, .from = openingBox(effect, target, p.screen_w), .to = target, - }; + }); self.tracks[id] = next; changed = true; animated_change = true; @@ -215,6 +215,21 @@ pub fn sync(self: *Presentation, p: *Pardes) void { if (changed and self.acknowledged) self.pending = true; } +/// A pane that moves (slides, zooms or rises) follows the Motion flavour: +/// its curve, and a length in proportion (animation.Motion.span). +fn stamp(p: *const Pardes, track: Track) Track { + var stamped = track; + switch (track.effect) { + .slide, .zoom, .vertical => { + const motion = animation.Motion.of(p.settings.motion); + stamped.motion = @intFromEnum(p.settings.motion) + 1; + stamped.frame_count = @intFromFloat(@max(2, @round(@as(f32, @floatFromInt(track.effect.frames())) * motion.span))); + }, + else => {}, + } + return stamped; +} + fn appendClosing(self: *Presentation, track: Track) bool { std.debug.assert(track.phase == .closing); const baseline = self.previous_layout[track.pane] orelse return false; @@ -388,6 +403,10 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32 .slide, .zoom, .vertical => { const target = track.contentBox(); if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue; + // Past its mark (a flavour's overshoot or wind-up) a pane + // is drawn over a neighbour; a point there is the + // neighbour's, never the moving pane's. + if (pastMark(track) and !boxContainsPoint(target, x, y)) continue; const shown = track.presented(); if (!boxContainsPoint(shown, x, y)) { if (track.effect == .vertical) return null; @@ -410,6 +429,13 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32 return .{ .x = x, .y = y }; } +/// A flavour's overshoot or wind-up: the pane is drawn beyond the path from +/// where it was to where it goes. +fn pastMark(track: Track) bool { + const t = track.amount(); + return t < 0 or t > 1; +} + fn boxContainsPoint(box: Box, x: f32, y: f32) bool { return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h; } @@ -436,6 +462,8 @@ pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u switch (track.effect) { .slide, .zoom => { const shown = track.presented(); + // Past its mark, the neighbour's cells stay the neighbour's. + if (pastMark(track) and !boxContainsCell(track.to, col, row)) continue; if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; @@ -688,6 +716,29 @@ test "presentation submission failure preserves shown geometry until retry is ac try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); } +test "a pane past its mark never takes a neighbour's click" { + // Bouncy, at its peak: pane 0 slides right to 0..40 and swings past it, + // over the neighbour at 40..80. + var presentation: Presentation = .{}; + var track: Track = .{ .serial = 7, .effect = .slide, .motion = @intFromEnum(animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = -40, .y = 0, .w = 40, .h = 20 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 20 } }; + var peak: u16 = 0; + var furthest: f32 = -1e9; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().x > furthest) { + furthest = track.presented().x; + peak = track.frame; + } + } + track.frame = peak; + try std.testing.expect(track.presented().x > 2); + presentation.shown_tracks[0] = track; + // A cell of the neighbour under the overshoot is the neighbour's own. + const col: u16 = 41; + try std.testing.expectEqual(CellPosition{ .col = col, .row = 5 }, presentation.pointer(80, 20, col, 5).?); + const point = presentation.pointerFractional(80, 20, 41.5, 5.5).?; + try std.testing.expectApproxEqAbs(@as(f32, 41.5), point.x, 1e-4); +} + test "fractional panel inverse preserves compact header rows through slide and zoom" { const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { diff --git a/src/animation.zig b/src/animation.zig index b02a242b..9b43644e 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -129,7 +129,9 @@ pub const Track = extern struct { pane: u8 = 0, phase: Phase = .moving, effect: Transition = .off, - _padding: u8 = 0, + /// The Motion flavour a pane's move follows, plus one; 0 is the + /// effect's own easing (Presentation.stamp). + motion: u8 = 0, frame: u16 = 0, frame_count: u16 = 0, from: Box = .{}, @@ -148,11 +150,47 @@ pub const Track = extern struct { // accelerates down out of the fixed clip. if (track.phase == .closing and track.effect == .vertical) return progressEased(.in_cubic, track.frames(), track.frame); + if (track.flavour()) |motion| + return motion.ease(track.progress()); return progressEased(track.effect.easing(), track.frames(), track.frame); } + /// The flavour an opening or moving pane follows; a closing one keeps + /// its effect's own exit. + fn flavour(track: Track) ?Motion { + if (track.motion == 0 or track.phase == .closing) return null; + const which = std.enums.fromInt(Motion.Flavour, track.motion - 1) orelse return null; + return Motion.of(which); + } + + fn progress(track: Track) f32 { + if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1; + return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1)); + } + pub fn presented(track: Track) Box { - return lerpBox(track.from, track.to, track.amount()); + // A flavour's wind-up goes back past where the move began. + const t = track.amount(); + // Landed, it is exactly where it goes: no stretch or float residue. + if (track.flavour() != null and track.progress() >= 1) return track.to; + var box = if (t < 0) lerpBox(track.to, track.from, 1 - t) else lerpBox(track.from, track.to, t); + // Squash and stretch: along its path a moving pane lengthens with + // its speed and, landing past its mark and coming back, squashes + // (the user's choice: its text with it; a pointer inverts the same + // box, so a click lands on the glyph it is drawn as). + const motion = track.flavour() orelse return box; + if (motion.stretch == 0) return box; + const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1); + if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) { + const w = box.w * factor; + box.x -= (w - box.w) / 2; + box.w = w; + } else { + const h = box.h * factor; + box.y -= (h - box.h) / 2; + box.h = h; + } + return box; } pub fn visualBox(track: Track) Box { @@ -613,20 +651,51 @@ pub const Motion = struct { /// Arcs: how far a two-dimensional path bows, as a fraction of its /// length (the cursor's glide). arc: f32 = 0, + /// Exaggeration: how far a small motion goes, as a multiple of its + /// plain distance (a lift of 1 rises `gain`), so a flavour's character + /// shows on a motion only a few pixels big. + gain: f32 = 1, + /// Timing: a move from rest leaves at this fraction of full speed (1: + /// out of the gate at once and easing in, 0: slow in and slow out). + launch: f32 = 0, + /// For a motion of fixed length (a pane's move, a notice's drop): its + /// length as a multiple of the effect's own, and the spring's frequency + /// over that length (`ease`). + span: f32 = 1, + norm: f32 = 10, pub fn of(flavour: Flavour) Motion { return switch (flavour) { .off => .{ .instant = true }, // Productivity: quick and exact, nothing past its mark. - .crisp => .{ .omega = 44 }, - // A longer, softer ease in and out, still without a bounce. - .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 }, - // Settles past and back, and stretches with its speed. - .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 }, - // A cartoon's: winds up, overshoots, stretches, follows through. - .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 }, + .crisp => .{ .omega = 60, .launch = 1, .span = 0.6, .norm = 10 }, + // Longer and softer, easing in and out, still without a bounce. + // The default: within §8.1's 220 ms for an arrival. + .smooth => .{ .omega = 34, .follow = 0.85, .arc = 0.04, .span = 1.1, .norm = 6 }, + // Clearly past its mark and back, once or twice, stretching + // with its speed. + .bouncy => .{ .omega = 24, .zeta = 0.35, .stretch = 0.15, .follow = 0.8, .arc = 0.08, .gain = 1.25, .span = 1.4, .norm = 13 }, + // A cartoon's: winds up, flies past, squashes as it lands, + // its followers trailing. + .playful => .{ .omega = 18, .zeta = 0.28, .anticipate = 0.15, .stretch = 0.35, .follow = 0.55, .arc = 0.15, .gain = 1.4, .span = 1.7, .norm = 18 }, }; } + + /// A move of fixed length from 0 to 1, `u` of the way through it: the + /// flavour's spring (wind-up, overshoot and all), landing exactly on 1 + /// at its end. `instant` is there at once. + pub fn ease(motion: Motion, u: f32) f32 { + if (motion.instant or u >= 1) return 1; + if (u <= 0) return 0; + const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false }; + return spring.value(@intFromFloat(u * std.time.ns_per_s)); + } + + /// The same move's speed at `u`, in lengths per unit of `u`. + pub fn speed(motion: Motion, u: f32) f32 { + const h: f32 = 1.0 / 256.0; + return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h)); + } }; /// A value that follows its target on a damped spring, in closed form @@ -684,8 +753,8 @@ pub const Spring = struct { return; } const state = spring.at(now_ns); - // From rest, a wind-up: set off the other way, just a little. - const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0; + // From rest, a launch, or a wind-up: set off the other way, a little. + const kick: f32 = if (spring.settled) (motion.launch - 2 * motion.anticipate) * (target - state.value) * motion.omega else 0; spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } @@ -712,7 +781,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" try std.testing.expect(v >= last and v <= 1); last = v; } - try std.testing.expect(t >= 150 * ms and t <= 260 * ms); + try std.testing.expect(t >= 80 * ms and t <= 200 * ms); try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. @@ -723,7 +792,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" const after = spring.at(60 * ms); try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6); try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4); - try std.testing.expect(spring.value(64 * ms) > before.value); + try std.testing.expect(spring.value(60 * ms + 500_000) > before.value); } test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { @@ -732,6 +801,7 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play spring.retarget(1, 0, Motion.of(.off)); try std.testing.expect(spring.settled); try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + var settle: [5]u64 = @splat(0); for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { spring = .{}; spring.retarget(1, 0, Motion.of(flavour)); @@ -744,17 +814,60 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play peak = @max(peak, now.value); low = @min(low, now.value); // Continuous, a millisecond at a time: no jump anywhere. - try std.testing.expect(@abs(now.value - last.value) < 0.05); + try std.testing.expect(@abs(now.value - last.value) < 0.07); last = now; } - try std.testing.expect(t < 1200 * ms); + try std.testing.expect(t < 1600 * ms); + settle[@intFromEnum(flavour)] = t; switch (flavour) { .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0), - .bouncy => try std.testing.expect(peak > 1.05 and low >= 0), - .playful => try std.testing.expect(peak > 1.05 and low < -0.01), + // Clearly past the mark, not a hint of it. + .bouncy => try std.testing.expect(peak > 1.25 and low >= 0), + .playful => try std.testing.expect(peak > 1.35 and low < -0.03), .off => unreachable, } } + // Smooth takes twice crisp's time and more: a character, not a tuning. + // Yet as the default a pane's arrival (a slide's 12 frames, scaled) is + // inside §8.1's 220 ms, and a lift is still before 300 ms. + try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] > 2 * settle[@intFromEnum(Motion.Flavour.crisp)]); + try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] <= 300 * ms); + try std.testing.expect(@round(@as(f32, 12) * Motion.of(.smooth).span) * frame_ms <= 220); + // A fixed-length move lands exactly on its mark, whatever the flavour. + for (std.enums.values(Motion.Flavour)) |flavour| try std.testing.expectEqual(@as(f32, 1), Motion.of(flavour).ease(1)); +} + +test "a flavour's stretched pane lands exactly on its box, and a click finds the glyph drawn there" { + const from: Box = .{ .x = 0, .y = 2, .w = 30, .h = 10 }; + const to: Box = .{ .x = 37.3, .y = 2, .w = 30, .h = 10 }; + for (std.enums.values(Motion.Flavour)) |flavour| { + var track: Track = .{ .effect = .slide, .motion = @intFromEnum(flavour) + 1, .frame_count = 20, .from = from, .to = to }; + track.frame = track.frames() - 1; + const landed = track.presented(); + // Bit for bit: no stretch or float residue once it lands. + try std.testing.expectEqual(to, landed); + } + // Playful at its most stretched: the pane is drawn wider than its box, + // and the drawn place of a cell maps back to that cell (the pointer + // inverts the same box: target + (x - shown.x) / shown.w * target.w). + var track: Track = .{ .effect = .slide, .motion = @intFromEnum(Motion.Flavour.playful) + 1, .frame_count = 20, .from = from, .to = to }; + var most: u16 = 0; + var widest: f32 = 0; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().w > widest) { + widest = track.presented().w; + most = track.frame; + } + } + track.frame = most; + const shown = track.presented(); + try std.testing.expect(shown.w > to.w * 1.05); + for (0..30) |k| { + const cell = to.x + @as(f32, @floatFromInt(k)) + 0.5; + const drawn = shown.x + (cell - to.x) / to.w * shown.w; + const back = to.x + (drawn - shown.x) / shown.w * to.w; + try std.testing.expectApproxEqAbs(cell, back, 1e-3); + } } /// A displayed value that fades from one target to the next over diff --git a/src/config.zig b/src/config.zig index 24e058c7..ce051be0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -636,6 +636,8 @@ pub const Runtime = struct { grip_width: u16 = 150, /// How the fx track's animations move (animation.Motion): off for /// reduced motion. + /// Smooth by default, tuned so arrivals land inside §8.1's 220 ms; the + /// slower flavours are a choice. motion: animation.Motion.Flavour = .smooth, /// Builtins announce themselves on the message row. diff --git a/src/draw.zig b/src/draw.zig index 1f30a40b..9df76cfd 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -134,11 +134,12 @@ pub fn place(p: *Pardes, s: *Surface) void { // snaps (it has no in-between), and so does nothing that shows it. const shown = lift != .off or inactiveDim(p) != 0; const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off); - spring.retarget(if (id == p.active) 1 else 0, p.now_ns, motion); + // A flavour exaggerates what is only a few pixels (Motion.gain). + spring.retarget(if (id == p.active) animation.Motion.of(p.settings.motion).gain else 0, p.now_ns, motion); _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { - .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0, + .grip, .tag, .body, .rail => region.lift = if (lift != .off) std.math.clamp(p.lifts[region.owner].value(p.now_ns), -1, 2) else 0, .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; @@ -416,7 +417,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { for (p.panes, p.lifts, 0..) |slot, spring, id| { if (slot == null or id == p.active) continue; - const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - spring.value(p.now_ns), 0, 1); + const focus = spring.value(p.now_ns) / animation.Motion.of(p.settings.motion).gain; + const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - focus, 0, 1); if (amount > 0.001) recede(p, s, id, amount); } @@ -1604,6 +1606,46 @@ test "with no lift and no dim a focus switch asks for no frames" { try std.testing.expect(!p.continuous()); } +test "each Motion flavour reaches the lift as its own trajectory" { + // A grid snaps; the flavours are a pixel shell's. + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.lift = .shadow; + var peaks: [5]f32 = undefined; + const ms = std.time.ns_per_ms; + var start: u64 = std.time.ns_per_s; + for (std.enums.values(animation.Motion.Flavour), &peaks) |flavour, *peak| { + p.settings.motion = flavour; + p.active = 1; + p.advance(start); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + start += 3 * std.time.ns_per_s; + p.advance(start); + _ = try p.render(arena.allocator()); + p.active = 0; + peak.* = 0; + var t: u64 = 0; + while (t <= 1500 * ms) : (t += 4 * ms) { + p.advance(start + t); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const lift = for (s.regionList()) |region| { + if (region.kind == .grip and region.owner == 0) break region.lift; + } else unreachable; + peak.* = @max(peak.*, lift); + } + start += 3 * std.time.ns_per_s; + } + // Bouncy and playful pass their (exaggerated) mark; smooth never does. + try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]); +} + test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { // The snapshot harness runs the tty, whose tagline pitch is the body's, // so it never reaches what a narrower tagline does to a notice's band: diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 1a1b1f99..9748d4c0 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5525,7 +5525,13 @@ fn renderFrame( const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); // A notice floats over the panes, moving with its own. const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index; + const start = cell_next[group_index]; emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); + // A notice dropping past its row (a flavour's overshoot) stays + // inside its pane's body. + if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| { + for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h); + }; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; @@ -5894,6 +5900,14 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } +/// The bottom of pane `owner`'s body, in window pixels. +fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 { + for (surface.regionList()) |region| { + if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h; + } + return null; +} + fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); @@ -6306,6 +6320,29 @@ test "every pixel of a grip column is band, button or ring, and the button sits pane.file.?.revision = pane.file.?.saved_revision; } +test "a rising pane past its mark stays inside its box" { + // Bouncy, a vertical rise at its highest overshoot: drawn past its box, + // clipped to it. + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } }; + var peak: u16 = 0; + var highest: f32 = 1e9; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().y < highest) { + highest = track.presented().y; + peak = track.frame; + } + } + track.frame = peak; + try std.testing.expect(track.presented().y < 0); + var instance: CellInstance = std.mem.zeroes(CellInstance); + setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0); + try std.testing.expectEqual(@as(f32, 0), instance.clip_y0); + try std.testing.expectEqual(@as(f32, 200), instance.clip_y1); +} + test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; diff --git a/src/macos.zig b/src/macos.zig index b472e6cc..acc344d2 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -2866,7 +2866,7 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@offsetOf(c.pardes_panel_box_s, field.name), @offsetOf(PanelBox, field.name)); try expectEqual(@sizeOf(c.pardes_panel_track_s), @sizeOf(PanelTrack)); inline for (@typeInfo(PanelTrack).@"struct".fields) |field| { - const c_name = comptime if (std.mem.eql(u8, field.name, "_padding")) + const c_name = comptime if (std.mem.eql(u8, field.name, "motion")) "reserved0" else field.name; @@ -3000,7 +3000,7 @@ test "mac panel ABI has the documented compact byte layout" { try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane")); try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase")); try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect")); - try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding")); + try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "motion")); try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame")); try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "frame_count")); try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from")); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 88947d0d..462ea40f 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -254,6 +254,8 @@ test "screen inspection preserves acknowledged presentation and the next real fr var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); for ([_]*Pardes{ control, inspected }) |p| { + // The mechanism, at a fixed flavour, not the default's tuning. + p.settings.motion = .crisp; p.settings.panel_transition = .off; p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); diff --git a/src/pardes.zig b/src/pardes.zig index 04ef4b36..eac21a14 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3580,6 +3580,8 @@ test "Newcol refuses an unsplittable restored weight before spawning" { test "layout commits publish finite tracks only for changed panes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3643,6 +3645,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; @@ -3666,6 +3670,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 4c19dc4f..c4f11cc8 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -15,4 +15,4 @@ 15-theme-acme-light 5cecfb32915c217c5d39492b8708532bd826a8a55ad6b8bd49d61ed5cee29ab9 16-debug 51628eb1d8dce799256e336d081d8510fe70b7a3f28f9efefcbca7126583b348 17-terminal 65794362c9e76fa2930e85e888f17216d24cace7475e29da3610c0dab411887b -18-mid-transition 72ce5905098af73e571a1efe009a700940458924502bcf5e96bfafe7c4b8f29c +18-mid-transition d365b05255d82a5cddd4c829493595a67b49db9ac67178f8499175c5cc0f706a |
