mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 07:27:04 +00:00
feat(gui): start the tile open-morph on click, before the launch fetches
A fresh launch has a server round-trip (app info, signing) between the click and the window's creation, so the open morph used to start late — on a slow connection the click gave no feedback at all. The two halves of the morph are now decoupled: - On click, begin_dashboard_tile_launch starts the icon's half immediately: the ghost enlarges in place and dissolves exactly as before, and the real icon stays hidden — the empty slot reads as 'launching' — while the fetches run. - When the window opens, morph_window_from_tile claims the click-time ghost instead of spawning a second one, runs the window half alone, and restores the icon when it lands. On a fast launch the halves overlap into one motion; on a slow one the window zooms out of the empty slot. - settle_dashboard_tile_launch (in the launch's finally) puts the icon back whenever the window never claims it: launch failed, app opened in background, or the morph fell back to the plain fade. The launch also gained a .catch, so a failed launch logs instead of leaking an unhandled rejection. The un-minimize path has no fetch and is untouched: it still spawns its own ghost inside the morph.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import launch_app from '../../helpers/launch_app.js';
|
||||
import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js';
|
||||
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
|
||||
|
||||
@@ -386,15 +387,26 @@ const TabApps = {
|
||||
// spawning a duplicate instance.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
self._launchingApps.add(appName);
|
||||
// morph_from_dashboard_tile: the new window opens by
|
||||
// morphing this tile's icon into it (see UIWindow) instead
|
||||
// of the plain opening fade.
|
||||
const tile = this;
|
||||
// Acknowledge the click NOW: the icon's half of the open
|
||||
// morph starts immediately, while the app's fetches are
|
||||
// still in flight; the window's half claims it when the
|
||||
// window opens (morph_from_dashboard_tile → see UIWindow),
|
||||
// and settle puts the icon back if it never does (launch
|
||||
// failed, or the morph fell back to the plain fade).
|
||||
begin_dashboard_tile_launch(tile);
|
||||
launch_app({
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
.finally(() => self._launchingApps.delete(appName));
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
self._launchingApps.delete(appName);
|
||||
settle_dashboard_tile_launch(tile);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+139
-29
@@ -4049,6 +4049,92 @@ function dashboard_tile_in_view (app_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch ghosts started at CLICK time, keyed by tile element. A fresh app
|
||||
* launch has a server round-trip between the click and the window's
|
||||
* creation; so the click can be acknowledged instantly, TabApps starts the
|
||||
* icon's half of the open morph immediately (begin_dashboard_tile_launch)
|
||||
* and the window's half claims it when the window finally opens
|
||||
* (morph_window_from_tile). Each entry: { ghost, icon, icon_orig_visibility,
|
||||
* claimed }.
|
||||
*/
|
||||
const tile_launch_ghosts = new WeakMap();
|
||||
|
||||
/**
|
||||
* Start a tile's launch feedback NOW, before the app's fetches resolve: the
|
||||
* icon's ghost enlarges in place and dissolves (exactly the icon half of
|
||||
* morph_window_from_tile), and the real icon stays hidden — an empty slot
|
||||
* reads as "launching" — until either the opening window's morph claims it
|
||||
* (and restores the icon when it lands) or settle_dashboard_tile_launch
|
||||
* restores it (launch failed, or the window opened without the morph).
|
||||
* No-op when the open morph itself wouldn't run (animations off, phone,
|
||||
* reduced motion) so the two halves always agree.
|
||||
*/
|
||||
export function begin_dashboard_tile_launch (tile) {
|
||||
const reduce_motion = window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if ( ! tile || ! window.animate_window_opening || isMobile.phone || reduce_motion ) return;
|
||||
if ( tile_launch_ghosts.has(tile) ) return;
|
||||
const icon = tile.querySelector('.myapps-tile-icon') || tile;
|
||||
const icon_rect = icon.getBoundingClientRect();
|
||||
if ( icon_rect.width <= 0 || icon_rect.height <= 0 ) return;
|
||||
|
||||
const ghost = icon.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.left = `${icon_rect.left}px`;
|
||||
ghost.style.top = `${icon_rect.top}px`;
|
||||
ghost.style.width = `${icon_rect.width}px`;
|
||||
ghost.style.height = `${icon_rect.height}px`;
|
||||
ghost.style.margin = '0';
|
||||
// The window this click opens doesn't exist yet; it will be created on
|
||||
// the same stay-on-top formula (99999999 + counter), so sit safely above
|
||||
// where it will land.
|
||||
ghost.style.zIndex = 99999999 + (window.last_window_zindex || 0) + 10;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
ghost.style.transformOrigin = 'center';
|
||||
ghost.style.willChange = 'transform, opacity';
|
||||
ghost.style.opacity = '1';
|
||||
ghost.style.transform = 'none';
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
tile_launch_ghosts.set(tile, {
|
||||
ghost,
|
||||
icon,
|
||||
icon_orig_visibility: icon.style.visibility,
|
||||
claimed: false,
|
||||
});
|
||||
icon.style.visibility = 'hidden';
|
||||
|
||||
// Same flight as the open morph's icon half: enlarge in place on the
|
||||
// decelerating curve, fully dissolved right around 200% (see
|
||||
// morph_window_from_tile for why the growth is capped).
|
||||
void ghost.offsetWidth;
|
||||
ghost.style.transition = [
|
||||
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'opacity 0.2s ease-out 0.12s',
|
||||
].join(', ');
|
||||
ghost.style.transform = 'scale(2)';
|
||||
ghost.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
ghost.remove();
|
||||
}, 480);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a click-time launch ghost once its launch attempt is over: if the
|
||||
* opening window's morph claimed it, the morph owns the icon restore and
|
||||
* this is a no-op; otherwise (launch failed, app opened in background, tile
|
||||
* scrolled out of view, morph fell back to the plain fade) put the icon
|
||||
* back. Call it from the launch's finally so both outcomes are covered.
|
||||
*/
|
||||
export function settle_dashboard_tile_launch (tile) {
|
||||
const state = tile && tile_launch_ghosts.get(tile);
|
||||
if ( ! state || state.claimed ) return;
|
||||
tile_launch_ghosts.delete(tile);
|
||||
state.ghost.remove();
|
||||
state.icon.style.visibility = state.icon_orig_visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse of hideWindow's zoom-to-tile morph: the tile's icon flies out
|
||||
* and inflates into the window while the window fades in — the same two
|
||||
@@ -4057,6 +4143,11 @@ function dashboard_tile_in_view (app_name) {
|
||||
* the mechanics (congruent layers, contain-fit ghost, compositor-only
|
||||
* animation); this mirrors it constant for constant.
|
||||
*
|
||||
* If begin_dashboard_tile_launch already started the icon's half at click
|
||||
* time, that ghost is claimed instead of spawning a second one: the window
|
||||
* half runs alone and the real icon (hidden since the click) is restored
|
||||
* when the morph lands.
|
||||
*
|
||||
* The window must be in the DOM, displayed, and at its final geometry —
|
||||
* geometry is never touched, and every inline property this sets is restored
|
||||
* byte-identical when the animation ends. Returns true if the morph started;
|
||||
@@ -4072,6 +4163,17 @@ function morph_window_from_tile (el_window, tile) {
|
||||
}
|
||||
|
||||
const $win = $(el_window);
|
||||
// A ghost started at click time (begin_dashboard_tile_launch) is
|
||||
// claimed instead of spawning a second one: it is already mid-flight
|
||||
// (or fully dissolved, on a slow launch) over the slot, and the real
|
||||
// icon — hidden since the click — becomes this morph's to restore
|
||||
// when it lands.
|
||||
const pre_launch = tile_launch_ghosts.get(tile);
|
||||
const claimed_ghost = !!(pre_launch && ! pre_launch.claimed);
|
||||
if ( claimed_ghost ) {
|
||||
pre_launch.claimed = true;
|
||||
tile_launch_ghosts.delete(tile);
|
||||
}
|
||||
// Everything touched is restored from these exact inline values once
|
||||
// the animation ends.
|
||||
const orig_style = {
|
||||
@@ -4084,7 +4186,9 @@ function morph_window_from_tile (el_window, tile) {
|
||||
pointerEvents: el_window.style.pointerEvents,
|
||||
transition: el_window.style.transition,
|
||||
};
|
||||
const icon_orig_visibility = icon.style.visibility;
|
||||
const icon_orig_visibility = claimed_ghost
|
||||
? pre_launch.icon_orig_visibility
|
||||
: icon.style.visibility;
|
||||
// Unlike the minimize morph (whose end states are inline values it
|
||||
// sets itself), this direction ends on the window's RESTING look, which
|
||||
// lives in the stylesheet — transitions need explicit end values, so
|
||||
@@ -4110,31 +4214,34 @@ function morph_window_from_tile (el_window, tile) {
|
||||
// capped: it fades out completely by ~97% of the decelerating path,
|
||||
// right around 200% of its size, and the window alone carries the
|
||||
// motion and the rest of the growth.
|
||||
const ghost = icon.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.left = `${icon_rect.left}px`;
|
||||
ghost.style.top = `${icon_rect.top}px`;
|
||||
ghost.style.width = `${icon_rect.width}px`;
|
||||
ghost.style.height = `${icon_rect.height}px`;
|
||||
ghost.style.margin = '0';
|
||||
// +2, not +1: showWindow's delayed focusWindow() bumps the window's
|
||||
// z-index once more mid-flight, and the ghost must stay above it.
|
||||
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 2;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
// 'center' so the scale below enlarges the ghost in place around its
|
||||
// own slot (the minimize morph's ghost uses 'top left' because it
|
||||
// translates as it flies; this one never moves).
|
||||
ghost.style.transformOrigin = 'center';
|
||||
ghost.style.willChange = 'transform, opacity';
|
||||
ghost.style.opacity = '1';
|
||||
ghost.style.transform = 'none';
|
||||
let ghost = null;
|
||||
if ( ! claimed_ghost ) {
|
||||
ghost = icon.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.left = `${icon_rect.left}px`;
|
||||
ghost.style.top = `${icon_rect.top}px`;
|
||||
ghost.style.width = `${icon_rect.width}px`;
|
||||
ghost.style.height = `${icon_rect.height}px`;
|
||||
ghost.style.margin = '0';
|
||||
// +2, not +1: showWindow's delayed focusWindow() bumps the window's
|
||||
// z-index once more mid-flight, and the ghost must stay above it.
|
||||
ghost.style.zIndex = (parseInt($win.css('z-index'), 10) || 1) + 2;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
// 'center' so the scale below enlarges the ghost in place around its
|
||||
// own slot (the minimize morph's ghost uses 'top left' because it
|
||||
// translates as it flies; this one never moves).
|
||||
ghost.style.transformOrigin = 'center';
|
||||
ghost.style.willChange = 'transform, opacity';
|
||||
ghost.style.opacity = '1';
|
||||
ghost.style.transform = 'none';
|
||||
document.body.appendChild(ghost);
|
||||
icon.style.visibility = 'hidden';
|
||||
}
|
||||
const ghost_scale = Math.min(
|
||||
2,
|
||||
win_rect.width / icon_rect.width,
|
||||
win_rect.height / icon_rect.height,
|
||||
);
|
||||
document.body.appendChild(ghost);
|
||||
icon.style.visibility = 'hidden';
|
||||
|
||||
$win.attr('data-window_morphing', '1');
|
||||
el_window.style.transition = 'none';
|
||||
@@ -4163,19 +4270,22 @@ function morph_window_from_tile (el_window, tile) {
|
||||
el_window.style.transform = end_transform;
|
||||
el_window.style.borderRadius = end_radius;
|
||||
el_window.style.opacity = '1';
|
||||
ghost.style.transition = [
|
||||
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'opacity 0.2s ease-out 0.12s',
|
||||
].join(', ');
|
||||
ghost.style.transform = `scale(${ghost_scale})`;
|
||||
ghost.style.opacity = '0';
|
||||
if ( ghost ) {
|
||||
ghost.style.transition = [
|
||||
'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'opacity 0.2s ease-out 0.12s',
|
||||
].join(', ');
|
||||
ghost.style.transform = `scale(${ghost_scale})`;
|
||||
ghost.style.opacity = '0';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// End of the zoom: the ghost has dissolved over the window's
|
||||
// center — drop it, un-hide the real icon, and put every touched
|
||||
// inline property back (the end states equal the resting computed
|
||||
// values, so nothing visibly changes).
|
||||
ghost.remove();
|
||||
// values, so nothing visibly changes). (A claimed click-time ghost
|
||||
// removes itself on its own timer; only the icon restore is ours.)
|
||||
if ( ghost ) ghost.remove();
|
||||
icon.style.visibility = icon_orig_visibility;
|
||||
$win.attr('data-window_morphing', '0');
|
||||
el_window.style.transition = 'none';
|
||||
|
||||
Reference in New Issue
Block a user