Merge PR #4420: make terminal fades snappier

* origin/max/tui-fade-snappy:
  fix(desktop): keep terminal fade compositor-stable

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-08-02 18:43:28 -04:00
2 changed files with 52 additions and 9 deletions

View File

@@ -8,20 +8,30 @@ import {
REVEAL_DURATION_MS,
} from "./fadeController.ts";
function deferred() {
let resolve;
const promise = new Promise((next) => {
resolve = next;
});
return { promise, resolve };
}
function fixture() {
const calls = [];
let resolveFinished;
const completions = [deferred()];
const animation = {
currentTime: 0,
playbackRate: 0,
finished: new Promise((resolve) => {
resolveFinished = resolve;
}),
finished: completions[0].promise,
play() {
calls.push("play");
completions.push(deferred());
this.finished = completions.at(-1).promise;
},
reverse() {
calls.push("reverse");
completions.push(deferred());
this.finished = completions.at(-1).promise;
},
cancel() {
calls.push("cancel");
@@ -37,8 +47,8 @@ function fixture() {
return {
animation,
calls,
completions,
controller: new FadeController(surface),
resolveFinished,
surface,
};
}
@@ -56,6 +66,39 @@ test("reversal reuses the one timeline and scales return playback", () => {
assert.equal(calls[0].options.easing, FADE_EASING);
});
test("fade durations are half the original timing", () => {
assert.equal(REVEAL_DURATION_MS, 360);
assert.equal(CONCEAL_DURATION_MS, 210);
});
test("completed fades retain the timeline but release its compositor hint", async () => {
const { calls, completions, controller, surface } = fixture();
assert.equal(controller.toggle(false), "reveal");
completions[0].resolve();
await Promise.resolve();
assert.equal(surface.style.willChange, "auto");
assert.equal(controller.toggle(false), "conceal");
assert.equal(calls.includes("cancel"), false);
assert.equal(surface.style.willChange, "opacity");
assert.equal(calls.filter((call) => typeof call === "object").length, 1);
});
test("a stale completion cannot release the hint during a reversal", async () => {
const { completions, controller, surface } = fixture();
controller.toggle(false);
controller.toggle(false);
completions[0].resolve();
await Promise.resolve();
assert.equal(surface.style.willChange, "opacity");
completions[1].resolve();
await Promise.resolve();
assert.equal(surface.style.willChange, "auto");
});
test("reduced motion settles immediately without allocating an animation", () => {
const { calls, controller, surface } = fixture();
assert.equal(controller.toggle(true), "reveal");

View File

@@ -1,5 +1,5 @@
export const REVEAL_DURATION_MS = 720;
export const CONCEAL_DURATION_MS = 420;
export const REVEAL_DURATION_MS = 360;
export const CONCEAL_DURATION_MS = 210;
export const FADE_EASING = "cubic-bezier(.2,.8,.2,1)";
type Direction = "reveal" | "conceal";
@@ -78,11 +78,11 @@ export class FadeController {
void animation.finished
.then(() => {
if (this.#animation === animation && this.#token === token) {
this.settle(next);
this.#surface.style.willChange = "auto";
}
})
.catch(() => {
// Reversal/cancellation rejects `finished`; the newer token owns state.
// Reversal/cancellation rejects `finished`; the live timeline owns state.
});
return next;
}