summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/config.md2
-rw-r--r--docs/effects.md37
-rw-r--r--src/Messages.zig10
-rw-r--r--src/Presentation.zig59
-rw-r--r--src/animation.zig147
-rw-r--r--src/config.zig2
-rw-r--r--src/draw.zig48
-rw-r--r--src/gui/gui.zig37
-rw-r--r--src/macos.zig4
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/pardes.zig6
-rw-r--r--test/gui-goldens.txt2
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