diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 8f3917715..ea3c9041a 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -386,7 +386,14 @@ const TabApps = { // spawning a duplicate instance. if ( self._launchingApps.has(appName) ) return; self._launchingApps.add(appName); - launch_app({ name: appName, maximized: true }) + // morph_from_dashboard_tile: the new window opens by + // morphing this tile's icon into it (see UIWindow) instead + // of the plain opening fade. + launch_app({ + name: appName, + maximized: true, + window_options: { morph_from_dashboard_tile: true }, + }) .finally(() => self._launchingApps.delete(appName)); } }); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 3182f49a0..162186c28 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -654,7 +654,32 @@ async function UIWindow (options) { $(el_window).focusWindow(); } - if ( window.animate_window_opening ) { + // A launch from a dashboard Apps-tab tile (TabApps passes + // morph_from_dashboard_tile) morphs the tile's icon into the opening + // window — the reverse of hideWindow's minimize morph. The window is + // already appended, displayed, and at its final (maximized) geometry + // here, and nothing has painted since it was appended, so the morph's + // collapsed start state applies without a flash of the full window. + // Falls through to the standard opening fade when the tile isn't + // visible (other tab, other pager page, phone, reduced motion). + let morphed_from_tile = false; + if ( options.morph_from_dashboard_tile && window.animate_window_opening + && options.is_visible && ! options.fadeIn && ! isMobile.phone + && ! (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ) { + const tile = dashboard_tile_in_view(options.app); + if ( tile ) { + // .window is display:none from the stylesheet until the show() + // further down; the morph needs the window laid out to measure + // it, so show it now — nothing has painted yet, so neither the + // early show nor the hide-back on failure ever flashes. + const was_hidden = ! $(el_window).is(':visible'); + if ( was_hidden ) $(el_window).show(); + morphed_from_tile = morph_window_from_tile(el_window, tile); + if ( ! morphed_from_tile && was_hidden ) $(el_window).hide(); + } + } + + if ( ! morphed_from_tile && window.animate_window_opening ) { // animate window opening $(el_window).css({ 'opacity': '0', @@ -3823,17 +3848,40 @@ $.fn.showWindow = async function (options) { const el_window = this; // A window minimized with no taskbar to animate toward (dashboard - // mode) was simply hidden in place by hideWindow — its geometry - // was never disturbed, so just un-hide it. (The explicit flag is - // used because data-orig-* can't distinguish the two minimize - // paths: drag/maximize handlers set it too.) + // mode) was hidden in place by hideWindow — its geometry was + // never disturbed. If the app's tile is visible on the Apps tab's + // current page, grow the window back out of it (the reverse of + // hideWindow's zoom-to-tile morph); otherwise just fade it back + // in as before. (The explicit flag is used because data-orig-* + // can't distinguish the two minimize paths: drag/maximize + // handlers set it too.) if ( $(el_window).attr('data-minimized_in_place') === '1' ) { $(el_window).attr({ 'data-is_minimized': false, 'data-minimized_in_place': '0', }); - $(el_window).fadeIn(150); $(el_window).css('z-index', ++window.last_window_zindex); + const reduce_motion = window.matchMedia + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // Skip the morph while another morph still owns the window's + // inline styles (reopen mid-minimize-zoom): clearing the + // flags above already makes that animation's cleanup leave + // the window visible and restored, same as before. + const tile = (reduce_motion || isMobile.phone + || $(el_window).attr('data-window_morphing') === '1') + ? null + : dashboard_tile_in_view($(el_window).attr('data-app')); + let morphed = false; + if ( tile ) { + // The morph needs the window displayed and laid out to + // measure it; no paint happens between this show() and + // the morph's start state, so nothing flashes. + const was_hidden = ! $(el_window).is(':visible'); + if ( was_hidden ) $(el_window).show(); + morphed = morph_window_from_tile(el_window, tile); + if ( ! morphed && was_hidden ) $(el_window).hide(); + } + if ( ! morphed ) $(el_window).fadeIn(150); setTimeout(() => { $(this).focusWindow(); }, 80); @@ -4001,6 +4049,150 @@ function dashboard_tile_in_view (app_name) { return null; } +/** + * 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 + * congruent layers on the same 450ms decelerating path, run backwards, so + * opening reads as the icon becoming the window. See hideWindow's morph for + * the mechanics (congruent layers, contain-fit ghost, compositor-only + * animation); this mirrors it constant for constant. + * + * 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; + * false if either box was unmeasurable (caller falls back to its fade). + */ +function morph_window_from_tile (el_window, tile) { + const icon = tile.querySelector('.myapps-tile-icon') || tile; + const win_rect = el_window.getBoundingClientRect(); + const icon_rect = icon.getBoundingClientRect(); + if ( win_rect.width <= 0 || win_rect.height <= 0 + || icon_rect.width <= 0 || icon_rect.height <= 0 ) { + return false; + } + + const $win = $(el_window); + // Everything touched is restored from these exact inline values once + // the animation ends. + const orig_style = { + transform: el_window.style.transform, + transformOrigin: el_window.style.transformOrigin, + opacity: el_window.style.opacity, + borderRadius: el_window.style.borderRadius, + overflow: el_window.style.overflow, + willChange: el_window.style.willChange, + pointerEvents: el_window.style.pointerEvents, + transition: el_window.style.transition, + }; + const 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 + // read the computed radius up front (the inline value goes back at the + // end either way). + const end_radius = getComputedStyle(el_window).borderRadius; + const end_transform = orig_style.transform || 'none'; + + // Same mapping as the minimize morph, driven backwards: the window + // starts collapsed onto the icon's box... + const scale_x = icon_rect.width / win_rect.width; + const scale_y = icon_rect.height / win_rect.height; + const dx = icon_rect.left - win_rect.left; + const dy = icon_rect.top - win_rect.top; + + // ...and the ghost simply enlarges IN PLACE on the real icon's slot, + // dissolving as the window grows out from underneath it. Unlike the + // minimize morph's contain-fit flight (where the huge ghost only + // becomes visible once it has shrunk near icon size), this ghost is + // visible from frame one — flown toward the window it visibly drifts + // away from its slot, and grown to full contain-fit (easily 10x+) it + // reads as a giant blurry sticker. So it stays put and its growth is + // capped: it fades out completely by ~97% of the decelerating path, + // right around 250% 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'; + const ghost_scale = Math.min( + 2.5, + 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'; + el_window.style.transformOrigin = 'top left'; + el_window.style.overflow = 'hidden'; // let the radius clip the content + el_window.style.willChange = 'transform, opacity'; + el_window.style.pointerEvents = 'none'; + el_window.style.transform = `translate(${dx}px, ${dy}px) scale(${scale_x}, ${scale_y})`; + // Percentage radius stays proportional under the scale — ~22% is the + // icon-squircle look. + el_window.style.borderRadius = '22%'; + el_window.style.opacity = '0'; + // Flush the starting state (both layers) so the transitions animate + // from it rather than snapping. + void el_window.offsetWidth; + + // The minimize morph's handoff, mirrored: the incoming layer (here the + // window) fades in at 80→240ms, the outgoing ghost lingers to 120→320ms, + // and both ride the same decelerating path — so there is never a hole + // and the swap is invisible: the icon "becomes" the window. + el_window.style.transition = [ + 'transform 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'border-radius 0.45s cubic-bezier(0.32, 0.72, 0, 1)', + 'opacity 0.16s ease-in 0.08s', + ].join(', '); + 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'; + + 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(); + icon.style.visibility = icon_orig_visibility; + $win.attr('data-window_morphing', '0'); + el_window.style.transition = 'none'; + el_window.style.transform = orig_style.transform; + el_window.style.transformOrigin = orig_style.transformOrigin; + el_window.style.opacity = orig_style.opacity; + el_window.style.borderRadius = orig_style.borderRadius; + el_window.style.overflow = orig_style.overflow; + el_window.style.willChange = orig_style.willChange; + el_window.style.pointerEvents = orig_style.pointerEvents; + setTimeout(() => { + el_window.style.transition = orig_style.transition; + }, 50); + }, 480); + return true; +} + // hides a window $.fn.hideWindow = async function (options) { $(this).each(async function () { @@ -4019,7 +4211,12 @@ $.fn.hideWindow = async function (options) { const $win = $(this); const reduce_motion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - const tile = (reduce_motion || isMobile.phone) + // (A morph already in flight — either direction — owns the + // window's inline transform/opacity and will restore them at + // its end, so don't start another on top; fall back to the + // plain fade below, exactly as before the morphs existed.) + const tile = (reduce_motion || isMobile.phone + || $win.attr('data-window_morphing') === '1') ? null : dashboard_tile_in_view($win.attr('data-app')); @@ -4100,6 +4297,7 @@ $.fn.hideWindow = async function (options) { $win.attr({ 'data-is_minimized': true, 'data-minimized_in_place': '1', + 'data-window_morphing': '1', }); el_window.style.transformOrigin = 'top left'; el_window.style.overflow = 'hidden'; // let the radius clip the content @@ -4141,6 +4339,7 @@ $.fn.hideWindow = async function (options) { // restore but leave it visible. ghost.remove(); icon.style.visibility = icon_orig_visibility; + $win.attr('data-window_morphing', '0'); const minimized = $win.attr('data-is_minimized'); if ( minimized === '1' || minimized === 'true' ) $win.hide(); el_window.style.transition = 'none';