From 36bad487a749176de6dbdd5884852eb66f57669d Mon Sep 17 00:00:00 2001 From: ZouaouiB Date: Fri, 23 Oct 2026 01:35:18 +0100 Subject: [PATCH] Windows/Linux: the island hops between screens instead of jumping When the island changes display ("Island lives on: the screen under the cursor", a display plugged in or out, Settings), the window jumped across in one frame. The island now tucks up into the top edge of the display it leaves (190 ms), the window moves while it is out of sight, waits for the new display's pixel ratio to settle, and drops down from the top edge of the next one with a few px of give (560 ms). - Changes that come in during a hop run while the island is still away: one visible hop, ending on the latest display. - A change that comes in during the drop takes over from where the island is. - Settings moves the window from Rust before the event: the island then only drops in. - Hidden island or reduced motion: it just moves, as before. The motion is on #root (#island keeps its own transform), and nothing is left on it once it has landed. --- windows/src/island/screenHop.ts | 114 ++++++++++++++++++++++++++++++++ windows/src/main.ts | 7 +- 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 windows/src/island/screenHop.ts diff --git a/windows/src/island/screenHop.ts b/windows/src/island/screenHop.ts new file mode 100644 index 0000000000..14a4ed3d23 --- /dev/null +++ b/windows/src/island/screenHop.ts @@ -0,0 +1,114 @@ +// The island changes screens like a notch would: it tucks up into +// the top edge of the display it leaves, the window moves while it's out of +// sight, and it drops down from the top edge of the next display, settling with +// a small give. Without it the window jumps across in one frame. + +const TUCK_MS = 190; +const DROP_MS = 560; +/** Past the island's bottom edge, so Mochi's overhang and glow go too. */ +const TUCK_MARGIN = 24; +const EASE_IN = "cubic-bezier(0.5, 0, 0.9, 0.4)"; +/** Decelerates into a few px of overshoot, then settles back. */ +const EASE_DROP = "cubic-bezier(0.22, 1.18, 0.36, 1)"; +/** Frames to wait at most for the window to land on the new display. */ +const LAND_FRAMES = 12; + +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); + +type Move = () => Promise; + +let hopping: Promise | null = null; +/** A display change that came in mid-hop, run while the island is still away. */ +let queued: Move | null = null; +/** Where the island was last placed: the window starts on its display. */ +let lastDisplay = display(); + +/** + * Runs `move` (which places the window on its display) inside the hop. + * `visible` says whether there is an island on screen to animate; a hidden + * one just moves. + */ +export function hopScreens(move: Move, visible: () => boolean): Promise { + if (hopping) { + queued = move; + return hopping; + } + hopping = hop(move, visible).finally(() => { + hopping = null; + }); + return hopping; +} + +async function hop(move: Move, visible: () => boolean) { + const root = document.getElementById("root"); + if (!root || !visible() || reducedMotion.matches) { + await moveAll(move); + return; + } + // A drop still running is taken over from where the island is. + const from = getComputedStyle(root); + const start = { transform: from.transform, opacity: Number(from.opacity) }; + const shift = new DOMMatrix(from.transform).m42; + for (const a of root.getAnimations()) a.cancel(); + + const away = { transform: `translateY(${-depth(shift)}px)`, opacity: 0 }; + // Settings → "Island lives on" has Rust move the window before this runs: + // it is already there, so it only drops in. + const tuck = display() === lastDisplay + ? root.animate([start, away], { duration: TUCK_MS, easing: EASE_IN, fill: "forwards" }) + : null; + await tuck?.finished.catch(() => {}); + try { + await moveAll(move); + await landed(); + } finally { + // Started before the tuck is dropped, in the same task: no frame shows the + // island back in place before it falls. + root.animate( + [ + { ...away, offset: 0 }, + { opacity: 1, offset: 0.35 }, + { transform: "none", opacity: 1, offset: 1 }, + ], + { duration: DROP_MS, easing: EASE_DROP }, + ); + tuck?.cancel(); + } +} + +/** Places the window, then again for any display change that came meanwhile. */ +async function moveAll(move: Move) { + let next: Move | null = queued ?? move; + while (next) { + queued = null; + await next(); + next = queued; + } + lastDisplay = display(); +} + +/** How far up the island has to go to leave the screen, from where it rests. */ +function depth(shift: number): number { + let bottom = 0; + for (const id of ["island", "bot-canvas"]) { + const el = document.getElementById(id); + if (el) bottom = Math.max(bottom, el.getBoundingClientRect().bottom - shift); + } + return Math.ceil(bottom) + TUCK_MARGIN; +} + +/** Waits for the window to settle on the new display: its pixel ratio stops changing. */ +async function landed() { + let last = window.devicePixelRatio; + for (let i = 0, still = 0; i < LAND_FRAMES && still < 2; i++) { + await new Promise((r) => requestAnimationFrame(r)); + still = window.devicePixelRatio === last ? still + 1 : 0; + last = window.devicePixelRatio; + } +} + +/** The display the window is on, as the webview sees it. */ +function display(): string { + const s = screen as Screen & { availLeft?: number; availTop?: number }; + return `${s.availLeft ?? 0},${s.availTop ?? 0},${s.width}x${s.height}`; +} diff --git a/windows/src/main.ts b/windows/src/main.ts index b199a83cfc..3ff0df07db 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -17,6 +17,7 @@ import { Recap } from "./recap/recap"; import { Updates } from "./island/update"; import { WhatsNew } from "./whats-new/controller"; import { onLanguageChange, resolveLanguage, setLanguage, systemLanguages } from "./i18n/i18n"; +import { hopScreens } from "./island/screenHop"; /** Shows the language Settings asks for ("" = the system's, when Coucou has it). */ function applyLanguage() { @@ -105,10 +106,10 @@ async function main() { await Bridge.fitZoom(dpr); }; - await onEvent("screen-changed", async () => { + await onEvent("screen-changed", () => void hopScreens(async () => { await checkZoom(); - void Bridge.reposition(); - }); + await Bridge.reposition(); + }, () => State.mode !== "hidden")); const mediaQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); mediaQuery.addEventListener?.("change", () => void checkZoom());