From deeb8ac54f71d4322a778336db9a7aec411c23ce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:44:51 -0300 Subject: G1: Lift, soft elevation shadows (off by default) `Lift` (a GUI toggle, capability lift) lifts the focused pane on a critically damped spring (animation.Spring: closed form, about 240 ms to settle, velocity kept across retargets, settled = no frames) and floats notices. The core puts the lift on the regions (Region.lift, on the wire); the GUI casts from it: a soft shadow is decor, an erf rectangle on one quad grown by 3 sigma (decor.frag, flag 0x10000000), in the quad's own space so a track's transform carries it; darken-only, the caster's rect left alone, alpha pre-warped for linear light and IGN-dithered. Cast last in its group, so a neighbour's rails and rules darken with its text; a notice's before its rule. Strength is capped per theme so text over the darkest shadow keeps min(its contrast, 4.5) (test over the native themes). Opt-in until the focus lift/dim default is decided. Feel review material: docs/effects.md. Test-mode PARDES_TEST_SERIES keeps every captured frame. Shared files touched: pardes.zig (two fields, one nextWake line), detached/wire.zig (lift on the region), config.zig, builtins.zig (capability), gui.zig. Not touched: Messages.zig, mouse.zig, tty, host_io. --- src/animation.zig | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) (limited to 'src/animation.zig') diff --git a/src/animation.zig b/src/animation.zig index 948a6679..ca933b82 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,6 +586,86 @@ pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; +/// A value that follows its target on a critically damped spring, in closed +/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken +/// partway keeps the value AND its velocity, so a quick run of changes never +/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump +/// after about 6.6 / omega seconds, and settles (half a percent, nearly +/// still) at about 8.6 / omega. Settled, it is exactly its target and asks +/// for no frames. +pub const Spring = struct { + /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a + /// state change (§8.1). + pub const state_change: f32 = 36; + + omega: f32 = state_change, + target: f32 = 0, + /// The value and velocity (per second) at `from_ns`. + from: f32 = 0, + velocity: f32 = 0, + from_ns: u64 = 0, + settled: bool = true, + + const State = struct { value: f32, velocity: f32 }; + + fn at(spring: *const Spring, now_ns: u64) State { + if (spring.settled) return .{ .value = spring.target, .velocity = 0 }; + const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s); + const w = spring.omega; + const x0 = spring.from - spring.target; + const c = spring.velocity + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay }; + } + + pub fn value(spring: *const Spring, now_ns: u64) f32 { + return spring.at(now_ns).value; + } + + /// Heads for `target` from wherever it is at `now_ns`, moving as it was. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void { + if (target == spring.target) return; + const state = spring.at(now_ns); + spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + } + + /// Settles once it is within half a percent of its target and barely + /// moving (no pixel of a shadow tells the rest): it is its target from + /// then on. True while it still moves. + pub fn step(spring: *Spring, now_ns: u64) bool { + if (spring.settled) return false; + const state = spring.at(now_ns); + if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true; + return !spring.settled; + } +}; + +test "a spring settles without overshoot and keeps its velocity when retargeted" { + var spring: Spring = .{}; + const ms = std.time.ns_per_ms; + spring.retarget(1, 0); + var last: f32 = 0; + var t: u64 = 0; + while (spring.step(t)) : (t += frame_ns) { + const v = spring.value(t); + try std.testing.expect(v >= last and v <= 1); + last = v; + } + // Settled within a frame or two of 6.6 / omega. + try std.testing.expect(t >= 180 * ms and t <= 260 * ms); + try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); + // Back the other way halfway up: it carries on up for a moment, then + // turns, never jumping. + spring = .{}; + spring.retarget(1, 0); + const before = spring.at(60 * ms); + spring.retarget(0, 60 * ms); + 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); +} + /// A displayed value that fades from one target to the next over /// `transition_steps` frames; the chrome colours are one. pub fn Fade(comptime Value: type) type { -- cgit v1.3 From 5dbd21795f6c68bd3c10d409135970e3033364f3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:24:18 -0300 Subject: Motion flavours, and Lift is shadow, rim or auto: auto on a dark page recedes the other panes (InactiveDim) Motion off|crisp|smooth|bouncy|playful is one parameter set (animation.Motion) every fx animation reads. One focus spring per pane drives both the lift and the dim, at the flavour's pace; a grid snaps. Lift drops glow and surface. A shadow falls on pane bodies and rails only, capped so text and the selection keep min(contrast, 4.5); rim is a hairline along the focused tag's top. auto is a shadow on a light page; on a dark page the core recedes the unfocused panes' text (InactiveDim, ported from 2e4d97f, default 30 under auto) with the same floor. Tests: focused text contrast is never below unfocused in any style, dim or theme; the focused pane's cells and tag are untouched by a dim. Shared files: src/config.zig, src/builtins.zig, src/ninep/ctl.zig, src/surface.zig, src/detached/wire.zig, docs/config.md, test/snapshots/builtins.snap (one more wheel for the longer help). --- docs/config.md | 15 ++ docs/effects.md | 54 ++++++- src/animation.zig | 139 ++++++++++++++---- src/builtins.zig | 2 +- src/config.zig | 50 +++++-- src/detached/wire.zig | 3 +- src/draw.zig | 330 +++++++++++++++++++++++++++++++++++++++++-- src/gui/gui.zig | 194 ++++++++++++++++++++----- src/ninep/ctl.zig | 2 + src/surface.zig | 3 + test/snapshots/builtins.snap | 3 + 11 files changed, 711 insertions(+), 84 deletions(-) (limited to 'src/animation.zig') diff --git a/docs/config.md b/docs/config.md index 16fd59e5..83fbd0eb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -527,6 +527,21 @@ the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where they are drawn. +The focused pane can stand off the page (SDL GUI, off by default): + +```text +Lift shadow a soft drop shadow, on the other panes' bodies only +Lift rim a hairline just above the focused tag (light, or shade on a light page) +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 +``` + +`InactiveDim` works everywhere (a grid shows it at once) and never takes a +pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30 +while unset. `Motion` sets how every such effect moves: see docs/effects.md. + `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; no checkout is needed, but the build must carry them (`-Dembed-sources=true`, diff --git a/docs/effects.md b/docs/effects.md index 44f3e949..fec2d13b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -1,5 +1,15 @@ # Effects: feel reviews +**The rule above all the others:** no effect may alter or cover a focus +indicator (the focused pane's tag colour, its grip, the cursor, the +selection) or reduce its contrast. The effects are sugar; the indicators are +how a person knows where they are. Every G stage is reviewed against this. +Lift, for one, falls only on pane bodies and rails, never on a tag, a grip or +a header, and its strength is capped so text and the selection keep min(their +contrast, 4.5) (tests in src/gui/gui.zig). A dim touches only the unfocused +panes, with the same floor, and the focused pane's text is never at less +contrast than theirs (tests in src/draw.zig). + Each effect of docs/render-pipeline.md §9 lands behind its own switch, off, and is kept only after a feel review (§8.4): a frame series on the virtual clock, a recording, and a verdict — keep, polish or drop — with one line of @@ -9,10 +19,42 @@ why. The verdict is the user's. |---|---|---|---|---| | Crt (bundled post pass) | `Crt 0..3` | kept, rewritten (no barrel) | fx-compare stills, live window | kept at the user's live look | | Ripple, Glitch | — | removed | live window | dropped by the user after a live look | -| G1 soft elevation shadows | `Lift` (off) | opt-in until the focus-lift default is decided | frame series + mp4 per theme (forge, acme, dusk): Lift on, a focus switch, a run of quick switches, a notice, Lift off | pending | +| G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim ` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) | + +## Motion flavours + +`Motion off|crisp|smooth|bouncy|playful` (default smooth) 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 | + +Which principles each motion uses: + +- **Lift** (G1): timing, slow in / slow out (the spring), follow-through + (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. +- **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. + -Notes on G1: the lift runs on a critically damped spring (§7.2) that settles -in about 240 ms and keeps its velocity when a quick run of switches retargets -it; a notice floats on a lift of 1 while Lift is on. The series are the core's -frames: core animation steps at 62.5 Hz on any display (§7.1), so a 144 Hz -display shows the same frames, each held for two or three refreshes. +Notes on G1: the lift and the dim run on one focus spring per pane, at the +Motion flavour's pace, sampled at each frame's own time; while it moves the +GUI draws at the display's rate, and a grid snaps. A notice floats on a lift +of 1 while a shadow or rim is on. InactiveDim under `Lift auto` defaults to +30. The fade is in linear light, so on a dark page it is gentle: at 30 +forge's text goes from 15.5:1 to 11.2:1 and dusk's from 6.4:1 to 4.8:1, +plainly quieter and still easy reading; at 10 forge barely moves (14.1:1). +On a light page the same percent bites far harder (acme: 7.0:1 at 10, the +4.5 floor at 30), which is why `auto` shades there instead of dimming. diff --git a/src/animation.zig b/src/animation.zig index ca933b82..b02a242b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,19 +586,58 @@ pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; -/// A value that follows its target on a critically damped spring, in closed -/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken -/// partway keeps the value AND its velocity, so a quick run of changes never -/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump -/// after about 6.6 / omega seconds, and settles (half a percent, nearly -/// still) at about 8.6 / omega. Settled, it is exactly its target and asks -/// for no frames. -pub const Spring = struct { - /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a - /// state change (§8.1). - pub const state_change: f32 = 36; +/// How the fx track moves (`Motion `, docs/effects.md): one set of +/// parameters a flavour picks, read by every animation that follows the +/// classic principles, so a flavour is data and no effect branches on it. +pub const Motion = struct { + pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful }; + + /// Nothing moves: every change lands at once (reduced motion). + instant: bool = false, + /// Timing: the spring's natural frequency, rad/s; slow in / slow out + /// come from the spring itself. + omega: f32 = 36, + /// Follow-through: 1 is critically damped (no overshoot); below 1 it + /// settles past its target and back. + zeta: f32 = 1, + /// Anticipation: a move from rest first winds up the other way, by + /// this fraction of the distance. + anticipate: f32 = 0, + /// Squash and stretch: how much a moving thing lengthens along its + /// velocity and squashes as it lands (G3's cursor, G7's panes). + stretch: f32 = 0, + /// Secondary action: how far behind a follower trails its leader (the + /// shadow behind a pane, a notice's text behind its chip), as the + /// follower's frequency over the leader's; 1 is in step. + follow: f32 = 1, + /// Arcs: how far a two-dimensional path bows, as a fraction of its + /// length (the cursor's glide). + arc: f32 = 0, + + 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 }, + }; + } +}; - omega: f32 = state_change, +/// A value that follows its target on a damped spring, in closed form +/// (docs/render-pipeline.md §7.2), critically damped or under: a new target +/// taken partway keeps the value AND its velocity, so a quick run of changes +/// never snaps or starts over. A move from rest can wind up first +/// (Motion.anticipate). Settled, it is exactly its target and asks for no +/// frames. +pub const Spring = struct { + omega: f32 = 36, + zeta: f32 = 1, target: f32 = 0, /// The value and velocity (per second) at `from_ns`. from: f32 = 0, @@ -613,20 +652,41 @@ pub const Spring = struct { const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s); const w = spring.omega; const x0 = spring.from - spring.target; - const c = spring.velocity + w * x0; - const decay = @exp(-w * t); - return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay }; + const v0 = spring.velocity; + if (spring.zeta >= 1) { + const c = v0 + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay }; + } + // Underdamped: it rings about its target as it decays. + const z = spring.zeta; + const wd = w * @sqrt(1 - z * z); + const b = (v0 + z * w * x0) / wd; + const decay = @exp(-z * w * t); + const cos = @cos(wd * t); + const sin = @sin(wd * t); + return .{ + .value = spring.target + decay * (x0 * cos + b * sin), + .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin), + }; } pub fn value(spring: *const Spring, now_ns: u64) f32 { return spring.at(now_ns).value; } - /// Heads for `target` from wherever it is at `now_ns`, moving as it was. - pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void { + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, + /// at the pace and damping `motion` gives: at once when it is `off`. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { if (target == spring.target) return; + if (motion.instant) { + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = target }; + return; + } const state = spring.at(now_ns); - spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + // 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; + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } /// Settles once it is within half a percent of its target and barely @@ -643,7 +703,8 @@ pub const Spring = struct { test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; - spring.retarget(1, 0); + const crisp = Motion.of(.crisp); + spring.retarget(1, 0, crisp); var last: f32 = 0; var t: u64 = 0; while (spring.step(t)) : (t += frame_ns) { @@ -651,21 +712,51 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" try std.testing.expect(v >= last and v <= 1); last = v; } - // Settled within a frame or two of 6.6 / omega. - try std.testing.expect(t >= 180 * ms and t <= 260 * ms); + try std.testing.expect(t >= 150 * ms and t <= 260 * ms); try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); - // Back the other way halfway up: it carries on up for a moment, then + // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. spring = .{}; - spring.retarget(1, 0); + spring.retarget(1, 0, crisp); const before = spring.at(60 * ms); - spring.retarget(0, 60 * ms); + spring.retarget(0, 60 * ms, crisp); 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); } +test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + spring.retarget(1, 0, Motion.of(.off)); + try std.testing.expect(spring.settled); + try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { + spring = .{}; + spring.retarget(1, 0, Motion.of(flavour)); + var peak: f32 = 0; + var low: f32 = 0; + var t: u64 = 0; + var last = spring.at(0); + while (spring.step(t)) : (t += ms) { + const now = spring.at(t); + 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); + last = now; + } + try std.testing.expect(t < 1200 * ms); + 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), + .off => unreachable, + } + } +} + /// A displayed value that fades from one target to the next over /// `transition_steps` frames; the chrome colours are one. pub fn Fade(comptime Value: type) type { diff --git a/src/builtins.zig b/src/builtins.zig index 7b2aca38..e49deb71 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -172,7 +172,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir, .lift => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true, }; unreachable; } diff --git a/src/config.zig b/src/config.zig index f7e83e91..ed7bee92 100644 --- a/src/config.zig +++ b/src/config.zig @@ -116,6 +116,8 @@ pub const leader_path = paths: { if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.lift) table.set(.Lift, null); + table.set(.Motion, null); + table.set(.InactiveDim, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -623,6 +625,13 @@ pub const Runtime = struct { /// docs/render-pipeline.md §9.1), or off. Opt-in until the focus lift's /// default is decided. Only a pixel shell draws it (`Capabilities.lift`). lift: LiftStyle = .off, + /// Blends the unfocused panes' text toward each cell's own ground, + /// percent (0 off): the focused pane wins by comparison, its tag tint + /// untouched. Kept above min(each cell's contrast, 4.5). + inactive_dim: u8 = 0, + /// How the fx track's animations move (animation.Motion): off for + /// reduced motion. + motion: animation.Motion.Flavour = .smooth, /// Builtins announce themselves on the message row. verbose: bool = true, @@ -670,12 +679,15 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// shadow: a soft drop shadow. surface: the lifted pane's ground - /// lightens, as a raised surface does on a dark theme. rim: a soft light - /// along its top and left edges. glow: a faint halo in the theme's - /// accent. auto: shadow on a light page, surface and a faint rim on a - /// dark one. - pub const LiftStyle = enum(u8) { off, shadow, surface, rim, glow, auto }; + /// shadow: a soft drop shadow. rim: a light hairline along the top of + /// the focused pane's tag rows. auto: a shadow on a light page; on a + /// dark one, where a shadow has nothing to darken, the other panes + /// recede instead (InactiveDim, at `auto_dim` unless it is set). + pub const LiftStyle = enum(u8) { off, shadow, rim, auto }; + /// What `Lift auto` dims the unfocused panes by on a dark page when + /// InactiveDim is 0: enough to read at a glance (the user's own init + /// sets 35), not so much that they stop being worth reading. + pub const auto_dim: u8 = 30; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { @@ -846,6 +858,10 @@ pub const Runtime = struct { dump_dir, /// A lift style, `on` (auto) or `off`; bare flips auto and off. lift, + /// A motion flavour. + motion, + /// A percentage, 0 through 100. + inactive_dim, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -881,6 +897,8 @@ pub const Runtime = struct { .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "Lift", .action = .lift, .availability = .lift }, + .{ .word = "Motion", .action = .motion }, + .{ .word = "InactiveDim", .action = .inactive_dim }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -1074,6 +1092,16 @@ pub const Runtime = struct { else std.meta.stringToEnum(LiftStyle, text) orelse return false; }, + .motion => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + state.motion = std.meta.stringToEnum(animation.Motion.Flavour, text) orelse return false; + }, + .inactive_dim => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); + const percent = std.fmt.parseInt(u8, text, 10) catch return false; + if (percent > 100) return false; + state.inactive_dim = percent; + }, .theme, .font => return false, } return true; @@ -1247,6 +1275,8 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) }) else try out.print("{s} unsupported\n", .{setting.word}), + .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), + .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), }; if (context.startup_config_path) |path| @@ -1514,7 +1544,9 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", - .lift => "glow", + .lift => "rim", + .motion => "playful", + .inactive_dim => "35", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1565,7 +1597,9 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", - .lift => "Lift glow\n", + .lift => "Lift rim\n", + .motion => "Motion playful\n", + .inactive_dim => "InactiveDim 35\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 0a066410..2a2ccc6b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -254,7 +254,8 @@ pub const Frame = struct { region.notice = try r.getByte(); region.clip = try r.getU16(); region.lift = @bitCast(try r.getU32()); - if (!(region.lift >= 0 and region.lift <= 1)) return error.BadValue; + // A springy flavour overshoots, and a wind-up dips below. + if (!(region.lift >= -1 and region.lift <= 2)) return error.BadValue; if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; diff --git a/src/draw.zig b/src/draw.zig index 98932c23..d1d4c11b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -18,6 +18,7 @@ const mouse = @import("mouse.zig"); const tagline = @import("tagline.zig"); const body_layer = @import("body_layer.zig"); const Messages = @import("Messages.zig"); +const builtins = @import("builtins.zig"); const Color = @import("surface.zig").Color; const FontRole = @import("surface.zig").FontRole; const CellStyle = @import("surface.zig").CellStyle; @@ -121,19 +122,24 @@ pub fn place(p: *Pardes, s: *Surface) void { // rail in the last cell: the layers under it stop a cell short. // Lift: the focused pane rises, the others settle back, each on its // spring; a notice floats while Lift is on. + const lift = liftStyle(p); for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| { const pane = slot orelse continue; if (serial.* != pane.serial) { serial.* = pane.serial; spring.* = .{}; } - // At this frame's own time, not the stepped animations' 16 ms grid. - spring.retarget(if (p.settings.lift != .off and id == p.active) 1 else 0, p.now_ns); + // How focused it is, which both a lift and a dim follow: at this + // frame's own time, not the stepped animations' 16 ms grid. A grid + // 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); _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { - .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.now_ns), - .notice => region.lift = if (p.settings.lift != .off) 1 else 0, + .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0, + .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; if (p.drag == .column_move) rail: { @@ -154,6 +160,162 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// The Lift style as drawn: `auto` is a shadow on a light page, and on a +/// dark one, where a shadow has nothing to darken, no lift at all (the other +/// panes recede instead, `inactiveDim`). +pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle { + return switch (p.settings.lift) { + .auto => if (darkPage(p)) .off else .shadow, + else => |style| style, + }; +} + +/// How far, in percent, the unfocused panes' text fades toward its ground: +/// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that +/// is unset. +pub fn inactiveDim(p: *const Pardes) u8 { + if (p.settings.inactive_dim != 0) return p.settings.inactive_dim; + return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0; +} + +fn darkPage(p: *const Pardes) bool { + return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true; +} + +/// sRGB channel to linear light. +const linear: [256]f32 = blk: { + @setEvalBranchQuota(100_000); + var table: [256]f32 = undefined; + for (&table, 0..) |*out, i| { + const c = @as(f64, @floatFromInt(i)) / 255; + out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4)); + } + break :blk table; +}; + +fn luminance(rgb: [3]u8) f32 { + return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]]; +} + +fn contrast(a: [3]u8, b: [3]u8) f32 { + const x = luminance(a); + const y = luminance(b); + return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); +} + +/// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid +/// and on every layer of it: its tag, its body, its notices. Its grip keeps +/// its colour, which already says it is not the focused one. +fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32) void { + const th = p.theme(); + const chrome = p.chromeTheme(); + const r = p.rects[id]; + const palette = th.palette; + var memo: Memo = .{}; + const text_x = r.x + @min(config.GUTTER, r.w); + var row = r.y; + while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) { + var col = text_x; + while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) { + const cell = s.at(col, row); + if (cell.default) continue; + const tagline_role = cell.style.font_role == .tagline; + fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo); + } + } + const body = &s.body_layers[id]; + if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo); + const tag = &s.tag_layers[id]; + if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo); + for (0..Pane.Notices.max) |i| { + const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; + if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo); + } +} + +/// The few ink and paper pairs a pane has, each faded once a frame. +const Memo = struct { + keys: [32]?[6]u8 = @splat(null), + values: [32][3]u8 = undefined, + + fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 { + const key = ink ++ paper; + const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len; + if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot]; + // Contrast falls as the ink nears its paper: the most of `amount` + // that keeps the pair at its own contrast or 4.5, whichever is lower. + const floor = @min(contrast(ink, paper), 4.5); + var t = amount; + if (contrast(blend(ink, paper, t), paper) < floor) { + var lo: f32 = 0; + var hi: f32 = t; + for (0..8) |_| { + const mid = (lo + hi) / 2; + if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid; + } + t = lo; + } + memo.keys[slot] = key; + memo.values[slot] = blend(ink, paper, t); + return memo.values[slot]; + } +}; + +/// A cell's ink blended `amount` of the way toward its paper, never below +/// the pair's own contrast or 4.5, whichever is lower (docs/effects.md): +/// faint resolved first, reverse honoured, as InactiveDim always did. +fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void { + if (style.invisible) return; + var fg = resolved(style.fg, default_fg, palette) orelse return; + const bg = resolved(style.bg, default_bg, palette) orelse return; + if (style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + const ink = if (style.reverse) bg else fg; + const paper = if (style.reverse) fg else bg; + const rgb = memo.faded(ink, paper, amount); + if (style.reverse) { + style.bg = .{ .rgb = rgb }; + if (style.dim) style.fg = .{ .rgb = fg }; + } else style.fg = .{ .rgb = rgb }; + style.dim = false; +} + +/// `t` of the way from one colour to the other in linear light (§8.2), so a +/// coloured ink does not muddy on its way to the paper. +fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, from, to) |*o, a, b| { + const l = linear[a] * (1 - t) + linear[b] * t; + o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))]; + } + return out; +} + +/// Linear light back to an sRGB channel, in 4096 steps. +const encoded: [4096]u8 = blk: { + @setEvalBranchQuota(1_000_000); + var table: [4096]u8 = undefined; + for (&table, 0..) |*out, i| { + const l = @as(f64, @floatFromInt(i)) / 4095; + const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055; + out.* = @intFromFloat(@round(c * 255)); + } + break :blk table; +}; + +fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 { + return switch (color) { + .rgb => |rgb| rgb, + .default => default, + // A theme's own sixteen, as the painters give them (Terminal.palColor). + .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: { + const rgb = @import("ghostty-vt").color.default[index]; + break :blk .{ rgb.r, rgb.g, rgb.b }; + } else null, + }; +} + pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; @@ -201,7 +363,9 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .scroll_track = chrome.scroll_track, .scroll_thumb = chrome.scroll_thumb, .accent = chrome.box, - .lift_style = @intFromEnum(p.settings.lift), + .sel_fg = p.theme().sel_fg, + .sel_bg = p.theme().sel_bg, + .lift_style = @intFromEnum(liftStyle(p)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -221,6 +385,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (region.kind == .notice) try joinNotice(p, s, arena, region); } + 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); + if (amount > 0.001) recede(p, s, id, amount); + } + try paintHeaders(p, s, arena); switch (p.drag) { @@ -1128,18 +1298,25 @@ test "a notice wider than its band, as a shader's compile error is, draws its en test "a lift is sampled at each frame's own time, and asks for frames only while it moves" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); + // A grid has no in-between: there a lift snaps. + if (!builtins.capabilities.lift) return error.SkipZigTest; _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; + p.settings.lift = .shadow; p.advance(1000 * ms); _ = try p.render(arena.allocator()); + p.advance(3000 * ms); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); - p.settings.lift = .shadow; + p.active = 0; var lifts: [3]f32 = undefined; // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own. for (&lifts, 0..) |*lift, i| { - p.advance(1000 * ms + @as(u64, @intCast(i)) * 6_944_444); + p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); lift.* = for (s.regionList()) |region| { @@ -1149,13 +1326,150 @@ test "a lift is sampled at each frame's own time, and asks for frames only while } try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]); // Settled, it is exactly up, and the core is idle again. - p.advance(2000 * ms); + p.advance(5000 * ms); _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } +/// The contrast a pane's first body glyph `g` is drawn at, on the grid. +fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 { + const r = p.rects[id]; + const th = p.theme(); + var row = p.bodyTop(p.panes[id].?, r); + while (row < r.y + r.h) : (row += 1) { + var col = r.x + config.GUTTER; + while (col < r.x + r.w) : (col += 1) { + const cell = s.at(col, row); + if (cell.default or cell.len != 1 or cell.text[0] != g) continue; + var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?; + const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?; + if (cell.style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + return contrast(fg, bg); + } + } + unreachable; +} + +test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const s_ns = std.time.ns_per_s; + var now: u64 = s_ns; + for (0..pardes.themes.len) |theme| { + colors.setThemeIndex(p, theme); + for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| { + p.settings.lift = style; + p.settings.inactive_dim = dim; + var seen: [2]f32 = undefined; + for (&seen, [_]usize{ 0, 1 }) |*c, focus| { + p.active = focus; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + now += 2 * s_ns; + p.advance(now); + _ = arena.reset(.retain_capacity); + c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't'); + } + try std.testing.expect(seen[0] >= seen[1] - 1e-3); + // And the unfocused one is still legible. + try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2); + }; + } +} + +test "a dim leaves the focused pane's tag, grip and cells exactly as they were" { + 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.active = 0; + p.advance(std.time.ns_per_s); + const plain = try p.render(arena.allocator()); + const r = p.rects[0]; + var before: std.ArrayList(Cell) = .empty; + defer before.deinit(std.testing.allocator); + for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]); + const tag_bg = plain.tag_layers[0].bg; + const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]); + defer std.testing.allocator.free(tag_cells); + p.settings.inactive_dim = 100; + p.advance(3 * std.time.ns_per_s); + const dimmed = try p.render(arena.allocator()); + var i: usize = 0; + for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| { + try std.testing.expect(cell.visuallyEqual(&before.items[i])); + i += 1; + }; + try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg); + for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b)); +} + +test "a dim never takes a pair below its own contrast or 4.5" { + for (pardes.themes) |theme| { + const fg = theme.fg orelse continue; + const bg = theme.bg orelse continue; + for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| { + var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint }; + var ink = fg; + if (faint) for (&ink) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + var memo: Memo = .{}; + fadeStyle(&style, null, null, null, 1, &memo); + const after = contrast(style.fg.rgb, style.bg.rgb); + try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2); + }; + } +} + +test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" { + var memo: Memo = .{}; + var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } }; + fadeStyle(&low, null, null, null, 1, &memo); + try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb); + // acme's bright white is black: faded, it stays a grey, never a white. + const acme = for (pardes.themes) |theme| { + if (std.mem.eql(u8, theme.name, "acme")) break theme; + } else unreachable; + var bright: CellStyle = .{ .fg = .{ .index = 15 } }; + memo = .{}; + fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo); + const rgb = bright.fg.rgb; + try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0); + // Reverse: the paper moves, the ink stays. + var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true }; + memo = .{}; + fadeStyle(&reverse, null, null, null, 0.3, &memo); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb); + try std.testing.expect(reverse.bg.rgb[0] < 255); +} + +test "with no lift and no dim a focus switch asks for no frames" { + 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.advance(std.time.ns_per_s); + _ = try p.render(arena.allocator()); + p.active = 0; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + try std.testing.expect(!p.continuous()); +} + 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 5d9783e3..d8bae6dc 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7448,55 +7448,90 @@ fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f const LiftCeilings = struct { shadow: f32, white: f32, - accent: f32, + /// A rim is light on a dark page and shade on a light one, where light + /// would not show. + rim: [3]u8, + /// Over the text on the page, and over a selection's text on its own + /// ground: the lower of the two. fn of(chrome: *const pardes.Chrome) LiftCeilings { const fg = chrome.fg orelse fg_default; const bg = chrome.page orelse bg_default; - return .{ .shadow = shadowCeiling(fg, bg), .white = lightenCeiling(fg, bg, .{ 255, 255, 255 }), .accent = lightenCeiling(fg, bg, chrome.accent) }; + return .{ + .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), + .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), + .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, + }; } }; /// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, -/// a notice's 1), in the style chosen; `auto` reads the page: a shadow on a -/// light one, a lighter surface and a faint rim on a dark one, where a -/// shadow has nothing to darken. -fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, chrome: *const pardes.Chrome, ceilings: LiftCeilings, caster: [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { +/// a notice's 1), in the style chosen: a soft shadow, or a rim along the +/// top of the focused pane's tag rows (`rim_edge`, a pane's only: +/// never a notice's). +fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (lift <= 0.001) return; const h: f32 = @floatFromInt(g.cell_h); - const dark_page = luminance(chrome.page orelse bg_default) < 0.18; - const parts: struct { shadow: bool = false, surface: bool = false, rim: f32 = 0, glow: bool = false } = switch (style) { + // The core sends `auto` already resolved (draw.liftStyle). + const shadow = switch (style) { .off => return, - .shadow => .{ .shadow = true }, - .surface => .{ .surface = true }, - .rim => .{ .rim = 1 }, - .glow => .{ .glow = true }, - .auto => if (dark_page) .{ .surface = true, .rim = 0.5 } else .{ .shadow = true }, + .shadow, .auto => true, + .rim => false, + }; + if (shadow) { + // Nothing of a shadow may touch a focus indicator (docs/effects.md): + // it falls on pane bodies and rails, never a tag, a grip or a header. + const first = g.decor.items.len; + try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, bodies); + } + // A hairline just above the focused tag rows, never on them: it adds to + // the indicator without changing its colour. + if (style == .rim) if (rim_edge) |edge| { + const light = ceilings.rim[0] == 255; + const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); + try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); }; - if (parts.surface) { - // A raised surface catches more light: its ground, and all on it, - // a few percent lighter, in whole pixels. - const a = @min(Lift.surface * lift, ceilings.white); - if (a > 0.002) try addDecor(g, gpa, caster[0], caster[1], caster[2], caster[3], .{ 255, 255, 255 }, a, track, win_w, win_h); - } - if (parts.shadow) try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); - // Light from above and to the left: a thin halo off the top and left - // edges, gone by the bottom and the right. - if (parts.rim > 0) try addSoft(g, gpa, caster, Lift.rim_sigma * h * lift, -Lift.rim_offset * h * lift, -Lift.rim_offset * h * lift, @min(Lift.rim * parts.rim * lift, ceilings.white), .{ 255, 255, 255 }, track, win_w, win_h); - if (parts.glow) try addSoft(g, gpa, caster, Lift.glow_sigma * h * lift, 0, 0, @min(Lift.glow * lift, ceilings.accent), chrome.accent, track, win_w, win_h); } -/// The lighter styles' strengths, in the §8.2 budget: coloured light at -/// most 25%, a surface a few percent. +/// A rim's strength, in the §8.2 budget for coloured light. const Lift = struct { - const surface: f32 = 0.055; - const rim: f32 = 0.22; - const rim_sigma: f32 = 0.12; - const rim_offset: f32 = 0.1; - const glow: f32 = 0.2; - const glow_sigma: f32 = 0.5; + const rim: f32 = 0.35; }; +/// A body region in window pixels. +fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { + return .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; +} + +/// Replaces the decor from `first` on with a copy per pane body it reaches, +/// each clipped to that body (and to what a vertical transition already +/// clipped it to): the lift then never reaches a tag, a grip or a header. +fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { + const added = g.decor.items.len - first; + if (added == 0) return; + var own: [8]CellInstance = undefined; + const n = @min(added, own.len); + @memcpy(own[0..n], g.decor.items[first..][0..n]); + g.decor.shrinkRetainingCapacity(first); + for (own[0..n]) |instance| for (bodies) |body| { + var copy = instance; + const vertical = copy.clip_x1 >= copy.clip_x0; + copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; + copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; + copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; + copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; + if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; + try g.decor.append(gpa, copy); + }; +} + + /// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as /// the decor pipeline blends a colour that is not black): the largest `a` /// that keeps text and page apart by min(their contrast, 4.5). @@ -7547,11 +7582,11 @@ fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); } -test "a rim, a glow or a surface never lightens a native theme's text below its contrast or 4.5" { +test "a rim never lightens a native theme's text below its contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; - for ([_][3]u8{ .{ 255, 255, 255 }, theme.box }) |light| { + for ([_][3]u8{.{ 255, 255, 255 }}) |light| { const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); const blend = struct { fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { @@ -7570,6 +7605,74 @@ test "a rim, a glow or a surface never lightens a native theme's text below its } } +test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { + const gpa = std.testing.allocator; + for ([_][]const u8{ "forge", "daybreak" }) |name| { + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + var line: [64]u8 = undefined; + try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); + for (0..40) |_| core.update(.tick); + for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { + core.settings.lift = style; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + const settled = try core.render(core.scratch.allocator()); + _ = s; + // The focus indicator itself: the focused pane's tag is not the + // others' colour, whatever the lift. + var active: ?[3]u8 = null; + var other: ?[3]u8 = null; + for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { + if (layer.id == core.active) active = layer.bg else other = layer.bg; + }; + try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); + var lifted: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 < item.clip_x0) continue; + lifted += 1; + // Inside a body or a rail, all of it. + const inside = for (settled.regionList()) |region| { + if (region.kind != .body and region.kind != .rail) continue; + const r = bodyPixels(region, fixedCellLayout(&g)); + if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; + } else false; + try std.testing.expect(inside); + } + const light_page = std.mem.eql(u8, name, "daybreak"); + try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); + // A rim is one hairline, just above the focused pane's tag row. + var rims: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 >= item.clip_x0) continue; + const top = (1 - item.y0) / 2 * 480; + const height = (item.y0 - item.y1) / 2 * 480; + const on_active_tag = for (settled.regionList()) |region| { + if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; + } else false; + // Not the rule under a column bar, which may run there too. + rims += @intFromBool(on_active_tag and item.fr < 1); + } + try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); + } + } +} + test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { @@ -7597,6 +7700,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const chrome = &surface.chrome; const regions = surface.regionList(); const ceilings = LiftCeilings.of(chrome); + var current_buf: [pardes.MAX_PANES][4]f32 = undefined; + var ncurrent: usize = 0; + for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { + current_buf[ncurrent] = bodyPixels(part, layout); + ncurrent += 1; + }; + const current_bodies = current_buf[0..ncurrent]; const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; const notice_lift = for (regions) |region| { if (region.kind == .notice) break region.lift; @@ -7638,6 +7748,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; + // Where a lift may fall: pane bodies, never tags (addLift). + var body_buf: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { + body_buf[nbodies] = bodyPixels(part, layout); + nbodies += 1; + }; + const bodies = body_buf[0..nbodies]; const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. @@ -7663,7 +7781,11 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); } - try addLift(g, gpa, lift_style, chrome, ceilings, box, grip.lift, track, win_w, win_h); + // The top edge of its tag rows, for a rim: x0, x1, y. + const rim_edge: ?[3]f32 = for (placed) |part| { + if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; + } else null; + try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is @@ -7676,7 +7798,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; - try addLift(g, gpa, lift_style, chrome, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, track, win_w, win_h); + try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 46c451ed..1c21d314 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -306,6 +306,8 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), + .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), + .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index e7149155..f16993a8 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -398,6 +398,9 @@ pub const Chrome = struct { scroll_thumb: [3]u8 = .{ 0, 0, 0 }, /// The theme's accent (a glow's tint). accent: [3]u8 = .{ 0, 0, 0 }, + /// A selection's text and ground: a lift keeps their contrast too. + sel_fg: [3]u8 = .{ 0, 0, 0 }, + sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, }; diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 84de680f..8bf70f36 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -27,6 +27,9 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 +wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter -- cgit v1.3 From c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 20:31:56 -0300 Subject: Motion flavours differ by design, and drive notice drops and pane moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/config.md | 2 +- docs/effects.md | 37 +++++++++---- src/Messages.zig | 10 +++- src/Presentation.zig | 59 +++++++++++++++++++-- src/animation.zig | 147 +++++++++++++++++++++++++++++++++++++++++++++------ src/config.zig | 2 + src/draw.zig | 48 +++++++++++++++-- src/gui/gui.zig | 37 +++++++++++++ src/macos.zig | 4 +- src/ninep/screen.zig | 2 + src/pardes.zig | 6 +++ test/gui-goldens.txt | 2 +- 12 files changed, 316 insertions(+), 40 deletions(-) (limited to 'src/animation.zig') 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 -- cgit v1.3