diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index b41d95bc7..41971498c 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -6,6 +6,24 @@ import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UI import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js'; +import { + APP_GROUPS_KV_KEY, + MAX_GROUP_APPS, + MAX_GROUP_NAME_LENGTH, + addAppToGroup, + buildGridItems, + createGroup, + defaultGroupName, + findGroupById, + flattenGridItems, + orderWithAppAfter, + parseAppGroups, + removeAppFromGroups, + removeGroup, + renameGroup, + reorderGroupApps, + serializeAppGroups, +} from './appGroups.js'; /** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */ const APP_NAMES_NO_UNINSTALL = new Set([ @@ -48,6 +66,38 @@ const TILE_REMOVE_DELAY_MS = 500; // pause between the uninstall modal closing // around each tile is what stops items flickering back and forth at a boundary. const DRAG_HIT_INSET = 0.28; +// -- Drag-to-group tuning (iOS folders) -- +// Hovering a tile is ambiguous: it means "push over, I'm passing through" AND +// "swallow me, let's be a folder". Pixels can't separate the two — the tile is +// barely bigger than the icon — so MOTION does: an icon that comes to REST on +// a tile opens it into a folder well, while one that carries on shuffles it +// aside on the way past. Hence the shuffle waits until the icon leaves the +// tile (or the drop settles it) rather than firing the moment it arrives: +// displacing on arrival would move the target out from under the very icon +// deciding to join it, and no folder could ever be made. +const DRAG_MERGE_DWELL_MS = 460; +// How still "at rest" is. Checked when the dwell elapses rather than as the +// pointer moves: a hand that is still drifting simply restarts the countdown, +// so the offer always arrives once the icon settles — and never while it is +// being carried across the grid. +const DRAG_MERGE_TRAVEL = 7; +// Once the well is open the target is sticky across its whole tile — a hand +// that drifts a few px must not silently undo the folder it is watching form. +const DRAG_MERGE_STICKY_PAD = 10; +const DRAG_MERGE_DROP_MS = 240; // ghost dropping into the folder it joined +// A tile dragged this far outside the open folder's card leaves the folder +// (iOS: drag an app out of a folder and it lands back on the home screen). +const GROUP_EJECT_MARGIN = 24; + +// -- Folder panel -- +const GROUP_ICON_MAX_TILES = 9; // the 3x3 mini-grid on a folder's icon +const GROUP_PANEL_OPEN_MS = 380; // keep in sync with .myapps-group-panel +const GROUP_PANEL_CLOSE_MS = 240; +// A brand-new folder opens itself so the user sees what their drop made — and +// lands on the name, because "Folder" is a placeholder, not an answer. The +// wait lets the drop animation finish first. +const GROUP_CREATE_OPEN_DELAY_MS = 260; + // How long a /app/ landing waits for the launching app's tile to be // visible before giving up on the click→morph→open intro and launching with // the plain fade (see beginDeepLinkLaunch). The wait covers the dashboard @@ -146,6 +196,73 @@ function buildTileHtml (app) { return h; } +// The label a folder shows: its own name, or the default one if a rename +// somehow left it blank (a nameless tile is one the user can't tell apart). +function groupLabel (group) { + return group.name || i18n('app_group_default_name', [], false); +} + +// A folder tile: same .myapps-tile skeleton as an app (so hover, focus, drag, +// FLIP, and the launch morph all treat it identically), with the icon slot +// filled by iOS's miniature grid of the apps inside instead of one icon. +// data-group-apps lets code outside this tab (UIWindow's minimize morph) find +// which folder an app lives in without reaching into the tab's state. +function buildGroupTileHtml (group, apps) { + const label = groupLabel(group); + const shown = apps.slice(0, GROUP_ICON_MAX_TILES); + const names = JSON.stringify(apps.map(app => app.name)); + + let h = `
`; + h += '
'; + h += '
'; + for ( const app of shown ) { + const iconUrl = app.icon === null + ? window.icons['app-default.svg'] + : (app.iconUrl || window.icons['app.svg']); + h += ``; + } + h += '
'; + h += '
'; + h += `${html_encode(label)}`; + h += '
'; + return h; +} + +// What a tile is, across a re-render that replaces every node: an app tile is +// its app, a folder tile is its folder. Used to match a tile to its old box +// when FLIP-animating the grid closing up. +function tileIdentity (tileEl) { + return tileEl.dataset.groupId + ? `group:${tileEl.dataset.groupId}` + : `app:${tileEl.dataset.appName || ''}`; +} + +// How many columns a CSS grid resolved to — arrow-key up/down inside the +// folder needs the folder's own column count, which (unlike the pager's) is +// decided by the stylesheet rather than computeLayout. +function gridColumnCount (gridEl) { + const tracks = getComputedStyle(gridEl).gridTemplateColumns; + if ( ! tracks || tracks === 'none' ) return 1; + return Math.max(1, tracks.split(' ').filter(Boolean).length); +} + +// The app names a folder tile carries (see buildGroupTileHtml). Corruption +// reads as an empty folder rather than throwing — the tile is still a tile. +function parseTileGroupApps (tileEl) { + try { + const names = JSON.parse(tileEl.dataset.groupApps || '[]'); + return Array.isArray(names) ? names : []; + } catch ( _e ) { + return []; + } +} + +function buildGridItemHtml (item) { + return item.type === 'group' + ? buildGroupTileHtml(item.group, item.apps) + : buildTileHtml(item.app); +} + function buildNoAppsHtml () { let h = '
'; h += ''; @@ -159,16 +276,18 @@ function buildNoAppsHtml () { // iOS-home-screen-style pager: fixed cols × rows pages in a horizontal // scroll-snap scroller, with page dots below and hover arrows for mouse users. -function buildPagerHtml (apps, layout, instant) { - const pageCount = Math.ceil(apps.length / layout.perPage); +// Takes grid ITEMS (see buildGridItems) — an app or a folder both occupy one +// slot, so paging and layout are blind to the difference. +function buildPagerHtml (items, layout, instant) { + const pageCount = Math.ceil(items.length / layout.perPage); let h = `
`; h += '
'; for ( let p = 0; p < pageCount; p++ ) { h += '
'; - for ( const app of apps.slice(p * layout.perPage, (p + 1) * layout.perPage) ) { - h += buildTileHtml(app); + for ( const item of items.slice(p * layout.perPage, (p + 1) * layout.perPage) ) { + h += buildGridItemHtml(item); } h += '
'; } @@ -259,13 +378,14 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { settleRemoval(); return; } - // FIRST: rects of the surviving tiles keyed by app name — the - // re-render replaces every node, so identity maps through names. + // FIRST: rects of the surviving tiles keyed by identity — the + // re-render replaces every node, so an app maps through its name + // and a folder through its id (it has no app name of its own). const firstRects = new Map(); if ( ! self._reduceMotion() ) { for ( const el of $el_window.find('.myapps-tile').toArray() ) { if ( el.dataset.appName === appName ) continue; - firstRects.set(el.dataset.appName, el.getBoundingClientRect()); + firstRects.set(tileIdentity(el), el.getBoundingClientRect()); } } self._apps.splice(idx, 1); @@ -273,7 +393,7 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { // FLIP the survivors from their old boxes into the new layout. const moved = []; for ( const el of $el_window.find('.myapps-tile').toArray() ) { - const a = firstRects.get(el.dataset.appName); + const a = firstRects.get(tileIdentity(el)); if ( ! a ) continue; const b = el.getBoundingClientRect(); const dx = a.left - b.left; @@ -389,6 +509,8 @@ const TabApps = { icon: '', _apps: null, + _groups: [], + _openGroupId: null, _layout: null, _page: 0, _pageCount: 0, @@ -403,6 +525,7 @@ const TabApps = { _pendingLoad: null, _savedOrderNames: null, _orderSavedAtSeq: 0, + _groupsSavedAtSeq: 0, _launchingApps: new Set(), html () { @@ -427,11 +550,13 @@ const TabApps = { init ($el_window) { // This object outlives a closed dashboard window; a re-init gets a - // fresh DOM that is not in reorder mode, whatever the old one was — - // and a pending empty-space press holds document-level listeners - // that must not survive the old DOM. + // fresh DOM that is not in reorder mode and has no folder open, + // whatever the old one had — and a pending empty-space press (or an + // open folder) holds document-level listeners that must not survive + // the old DOM. this._reorderMode = false; this._cancelEmptyPress(); + this._closeGroup($el_window, { instant: true }); this.loadApps($el_window); @@ -456,7 +581,7 @@ const TabApps = { // A detached target means some handler already reshaped the DOM // under this click — whatever it was, it wasn't empty space. if ( ! e.target.isConnected ) return; - if ( $(e.target).closest('.myapps-tile, .myapps-tile-remove, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow').length ) return; + if ( $(e.target).closest('.myapps-tile, .myapps-tile-remove, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow, .myapps-group-overlay').length ) return; self._setReorderMode($el_window, false); }); @@ -507,14 +632,20 @@ const TabApps = { $el_window.on('click', '.myapps-tile', function (e) { e.preventDefault(); e.stopPropagation(); - // In reorder mode a press on a tile is a (potential) drag pickup, - // never a launch. - if ( self._reorderMode ) return; - // A click synthesized at the end of a drag must not open the app. + // A click synthesized at the end of a drag must not open anything. if ( self._justDragged ) { self._justDragged = false; return; } + // Folders open in every mode — inside reorder mode that is the + // only way to rearrange or empty one. + if ( this.dataset.groupId ) { + self._openGroup($el_window, this.dataset.groupId, this); + return; + } + // In reorder mode a press on a tile is a (potential) drag pickup, + // never a launch. + if ( self._reorderMode ) return; const appName = $(this).attr('data-app-name'); const targetLink = $(this).attr('data-target-link'); // Ctrl/Cmd+click opens in a new browser tab, mirroring the @@ -527,8 +658,17 @@ const TabApps = { } return; } + // Launching from inside a folder closes it once the app is up — + // the folder was the way in, not the destination (iOS shuts it + // behind the opening app). Deferred to the launch's settle so the + // tile is still there for the window's morph to grow out of. + const fromFolder = !! this.closest('.myapps-group-panel-grid'); + const closeFolder = () => { + if ( fromFolder ) self._closeGroup($el_window); + }; if ( targetLink && targetLink !== '' ) { window.open(targetLink, '_blank', 'noopener,noreferrer'); + closeFolder(); } else if ( appName ) { // One instance per app when launched from here: un-hide a // minimized instance / focus a visible one rather than @@ -542,6 +682,7 @@ const TabApps = { } else { $win.focusWindow(); } + closeFolder(); return; } // A second click while the first launch's fetches are still in @@ -568,6 +709,7 @@ const TabApps = { .finally(() => { self._launchingApps.delete(appName); settle_dashboard_tile_launch(tile); + closeFolder(); }); } }); @@ -588,6 +730,40 @@ const TabApps = { e.preventDefault(); return; } + // A pending pickup (button held, not yet moved) would be stranded + // under the menu; cancel it so the two can't run at once. An + // already-started drag was handled by the guard above. + if ( self._drag ) self._endDrag(false); + + const groupId = this.dataset.groupId; + if ( groupId ) { + e.preventDefault(); + e.stopPropagation(); + const tile = this; + UIContextMenu({ + parent_element: $(this), + position: { top: e.clientY, left: e.clientX }, + items: [ + { + html: i18n('app_group_open'), + onClick: () => self._openGroup($el_window, groupId, tile), + }, + { + html: i18n('app_group_rename'), + // Renaming happens in the folder's own header — + // one place to edit the name, seen in context. + onClick: () => self._openGroup($el_window, groupId, tile, { editName: true }), + }, + '-', + { + html: i18n('app_group_ungroup'), + onClick: () => self._ungroup($el_window, groupId), + }, + ], + }); + return; + } + const appName = $(this).attr('data-app-name'); const appTitle = $(this).attr('data-app-title'); const appUid = $(this).attr('data-app-uid'); @@ -624,6 +800,14 @@ const TabApps = { }, }); } + // Inside an open folder: the pointer-free way out of it, for + // anyone who won't (or can't) drag the tile past the card's edge. + if ( this.closest('.myapps-group-panel-grid') && self._openGroupId ) { + items.push({ + html: i18n('app_group_remove_from_folder'), + onClick: () => self._ejectFromGroup($el_window, appName), + }); + } if ( ! noUninstall ) { items.push('-', { html: 'Uninstall', @@ -639,11 +823,6 @@ const TabApps = { }); } - // A pending pickup (button held, not yet moved) would be stranded - // under the menu; cancel it so the two can't run at once. An - // already-started drag was handled by the guard above. - if ( self._drag ) self._endDrag(false); - e.preventDefault(); e.stopPropagation(); @@ -689,11 +868,13 @@ const TabApps = { // -- Keyboard navigation -- // Arrow keys move focus between the current page's tiles and - // Enter/Space launches the focused one. Navigation is deliberately - // clamped to the visible page — the keyboard never flips pages; the - // dots, hover arrows, and wheel remain the paging affordances. - // updatePagerUI keeps one tile per render in the tab order (roving - // tabindex), so Tab lands on the grid and arrows take over from there. + // Enter/Space launches the focused one (a folder tile opens instead). + // Navigation is deliberately clamped to the visible page — the + // keyboard never flips pages; the dots, hover arrows, and wheel remain + // the paging affordances. updatePagerUI keeps one tile per render in + // the tab order (roving tabindex), so Tab lands on the grid and arrows + // take over from there. While a folder is open the same arrows walk + // ITS tiles: the grid behind is inert, so focus must not be there. $(document).off('keydown.myapps-keyboard').on('keydown.myapps-keyboard', function (e) { if ( ! ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' '].includes(e.key) ) return; if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return; @@ -701,7 +882,11 @@ const TabApps = { if ( $el_window.find('.myapps-modal-overlay').length ) return; if ( $('.window').not($el_window[0]).filter(':visible').length ) return; - const pageTiles = $el_window.find('.myapps-page').eq(self._page).find('.myapps-tile').toArray(); + const $panelGrid = $el_window.find('.myapps-group-panel-grid'); + const pageTiles = ($panelGrid.length + ? $panelGrid + : $el_window.find('.myapps-page').eq(self._page) + ).find('.myapps-tile').toArray(); if ( pageTiles.length === 0 ) return; const ae = document.activeElement; @@ -737,7 +922,9 @@ const TabApps = { pageTiles[0].focus({ preventScroll: true }); return; } - const cols = (self._layout && self._layout.cols) || 1; + const cols = $panelGrid.length + ? gridColumnCount($panelGrid[0]) + : ((self._layout && self._layout.cols) || 1); let next = idx; if ( e.key === 'ArrowLeft' ) next = idx - 1; else if ( e.key === 'ArrowRight' ) next = idx + 1; @@ -836,7 +1023,14 @@ const TabApps = { }); } - if ( list.length === 0 ) { + // Search looks THROUGH folders: a query is a question about apps, and + // an answer the user then has to go hunting inside a folder for is not + // an answer. Unfiltered, the grid folds into folders as usual. + const items = query + ? list.map(app => ({ type: 'app', app })) + : buildGridItems(list, this._groups); + + if ( items.length === 0 ) { this._layout = null; this._page = 0; this._pageCount = 0; @@ -858,12 +1052,16 @@ const TabApps = { : 0; this._layout = layout; - this._pageCount = Math.ceil(list.length / layout.perPage); + this._pageCount = Math.ceil(items.length / layout.perPage); this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1); - $container.html(buildPagerHtml(list, layout, instant)); + $container.html(buildPagerHtml(items, layout, instant)); revealWhenLoaded($container); this.updateRunningDots($el_window); + // An open folder outlives the grid rebuilds underneath it (a + // background refresh must not slam it shut mid-browse); its contents + // come from the same state, so they refresh here too. + this._refreshGroupPanel($el_window); const scroller = $container.find('.myapps-pager-scroller')[0]; if ( this._page > 0 ) { @@ -891,12 +1089,16 @@ const TabApps = { }, // Mark tiles whose app has a live window (visible OR minimized — both - // are running) with the macOS-dock-style dot. Cheap enough to re-run + // are running) with the macOS-dock-style dot. A folder wears the dot when + // anything inside it is running — the apps it hides are exactly the ones + // whose state the user can't otherwise see. Cheap enough to re-run // wholesale on every open/close/render. updateRunningDots ($el_window) { + const isRunning = name => !! name && $(`.window[data-app="${html_encode(name)}"]`).length > 0; for ( const tile of $el_window.find('.myapps-tile').toArray() ) { - const name = tile.getAttribute('data-app-name'); - const running = !! name && $(`.window[data-app="${html_encode(name)}"]`).length > 0; + const running = tile.dataset.groupId + ? parseTileGroupApps(tile).some(isRunning) + : isRunning(tile.getAttribute('data-app-name')); tile.classList.toggle('myapps-tile-running', running); } }, @@ -1017,7 +1219,10 @@ const TabApps = { // would confuse hover-capable touchscreen laptops). if ( ! isTouchPrimaryDevice() ) return; if ( ! this._apps || this._apps.length < 2 ) return; - if ( $(oe.target).closest('.myapps-tile, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow, .myapps-search-inner, .myapps-modal-overlay').length ) return; + // The folder card's own empty space counts: with the grid's behind a + // backdrop, holding there is the only way into reorder mode from + // inside a folder. Its backdrop doesn't — a tap there closes. + if ( $(oe.target).closest('.myapps-tile, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow, .myapps-search-inner, .myapps-modal-overlay, .myapps-group-backdrop, .myapps-group-name').length ) return; const p = this._emptyPress = { pointerId: oe.pointerId, @@ -1060,6 +1265,399 @@ const TabApps = { document.removeEventListener('pointercancel', p.onEnd); }, + // -- Folders -- + // A folder opens the way iOS opens one: the grid recedes behind a blurred + // scrim and the folder grows out of its own icon into a card — so the + // enlargement reads as "this tile, opened", not "a dialog appeared". The + // card is a plain overlay inside the tab (not a UIWindow): it belongs to + // this grid, moves with the dashboard window, and closes on Escape, on a + // click outside, or on the tile that opened it. + + _openGroup ($el_window, groupId, fromTile, { editName = false } = {}) { + const group = findGroupById(this._groups, groupId); + if ( ! group ) return; + // Clicking the open folder's own tile again closes it, like iOS. + if ( this._openGroupId === groupId ) { + this._closeGroup($el_window); + return; + } + if ( this._openGroupId ) this._closeGroup($el_window, { instant: true }); + // A folder closed a moment ago is still fading out; two cards on + // screen would make every $overlay lookup below ambiguous, so the + // outgoing one goes now and the incoming one takes over. + $el_window.find('.myapps-group-overlay').remove(); + + this._openGroupId = groupId; + // Where focus came from, so closing can hand it back rather than + // dumping the keyboard user at the top of the document. + this._groupReturnFocus = fromTile || null; + + const $overlay = $(` +
+
+ +
+ `); + $el_window.find('.dashboard-tab-content.myapps-tab').append($overlay); + this._refreshGroupPanel($el_window); + // The refresh shuts a folder that turned out to have nothing to show + // (its apps failed to load); there is then no panel to wire up. + if ( this._openGroupId !== groupId ) return; + this._bindGroupPanel($el_window, $overlay); + this._animateGroupPanelOpen($el_window, fromTile); + + if ( editName && ! isTouchPrimaryDevice() ) { + // Naming is the first thing a new folder wants; on touch the same + // gesture would throw an on-screen keyboard over the folder the + // user just made, so there they tap the name when ready. + const input = $overlay.find('.myapps-group-name')[0]; + input.focus(); + input.select(); + } else { + // The DOM node, not the jQuery wrapper: jQuery reads a lone + // object argument to .focus() as event DATA and binds a handler + // with it instead of moving focus — so the folder opened with + // focus still on the inert grid behind it (Tab then walked off + // through that grid, past this dialog's trap), and the bogus + // handler threw on the tile's every later focus. + const first = $overlay.find('.myapps-tile')[0]; + if ( first ) first.focus({ preventScroll: true }); + } + }, + + _bindGroupPanel ($el_window, $overlay) { + const self = this; + + // Anywhere outside the card — including the strip of scrim beside it. + $overlay.on('click', function (e) { + if ( e.target === this || $(e.target).hasClass('myapps-group-backdrop') ) { + self._closeGroup($el_window); + } + }); + + // A press inside the overlay must not let initgui's global mousedown + // hand focus (and the z-index) back to the window underneath it. + $overlay.on('mousedown', function () { + window.mouseover_window = undefined; + }); + + // Escape closes from anywhere — a click on the scrim leaves focus on + // no element in particular, and the key must still work there. Not + // mid-drag, where Escape already means "cancel this drag". + this._groupEscHandler = e => { + if ( e.key !== 'Escape' || this._drag ) return; + if ( $el_window.find('.myapps-modal-overlay').length ) return; + this._closeGroup($el_window); + }; + document.addEventListener('keydown', this._groupEscHandler); + + $overlay.on('keydown', function (e) { + // The folder is modal: Tab cycles inside it rather than walking + // off into the inert grid behind. + if ( e.key !== 'Tab' ) return; + const focusables = $overlay.find('.myapps-group-name, .myapps-tile').toArray() + .filter(el => el.offsetParent !== null); + if ( focusables.length === 0 ) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const idx = focusables.indexOf(document.activeElement); + // idx -1: focus sits on the card itself (its tabindex=-1 catches + // clicks on the card's empty space, keeping this trap in reach) — + // going backwards from there must wrap inside too, not step off + // through the scrim. + if ( e.shiftKey && idx <= 0 ) { + e.preventDefault(); + last.focus(); + } else if ( ! e.shiftKey && idx === focusables.length - 1 ) { + e.preventDefault(); + first.focus(); + } + }); + + const $name = $overlay.find('.myapps-group-name'); + // Enter commits (and gets out of the way); blur commits too, so a + // click straight onto an app in the folder keeps the new name. + $name.on('keydown', function (e) { + if ( e.key === 'Enter' ) { + e.preventDefault(); + // The name box owns this Enter: the grid's document-level key + // handler reads Enter on a focused tile as "launch it", and + // the focus move below would hand it exactly that — the same + // keystroke would name the folder AND open an app out of it. + e.stopPropagation(); + // Step out onto the folder's contents rather than just + // blurring: a bare blur leaves focus on , outside this + // dialog, where the next Tab walks off into the inert grid + // the folder is covering. Moving focus still commits the + // name — that is the same blur. + const first = $overlay.find('.myapps-group-panel-grid .myapps-tile')[0]; + if ( first ) first.focus({ preventScroll: true }); + else this.blur(); + } + if ( e.key === 'Escape' ) { + const group = findGroupById(self._groups, self._openGroupId); + const stored = group ? groupLabel(group) : ''; + // An edit in progress: Escape means "never mind THIS EDIT" + // (the convention of every inline rename), not "close the + // folder" — left to propagate, the document handler would + // close it and _closeGroup's commit-on-close would store the + // very half-typed name being abandoned. Put the stored name + // back and step out of the box; the next Escape, with no + // edit left to cancel, closes the folder as usual. + if ( this.value === stored ) return; + e.preventDefault(); + e.stopPropagation(); + this.value = stored; + const first = $overlay.find('.myapps-group-panel-grid .myapps-tile')[0]; + if ( first ) first.focus({ preventScroll: true }); + else this.blur(); + } + }); + $name.on('change blur', function () { + self._renameGroup($el_window, self._openGroupId, this.value); + }); + }, + + // Rebuild the open folder's contents from the current state — called on + // every render, so a background refresh, an uninstall, or a drag all keep + // the card truthful. Closes it if the folder is gone. + _refreshGroupPanel ($el_window) { + if ( ! this._openGroupId ) return; + const $overlay = $el_window.find('.myapps-group-overlay'); + if ( $overlay.length === 0 ) return; + + const group = findGroupById(this._groups, this._openGroupId); + const apps = group + ? group.apps + .map(name => (this._apps || []).find(app => app.name === name)) + .filter(Boolean) + : []; + // Under two apps it is not a folder any more (the grid draws the + // survivor as a plain tile); there is nothing left to browse. + if ( apps.length < 2 ) { + this._closeGroup($el_window, { instant: true }); + return; + } + + const $name = $overlay.find('.myapps-group-name'); + // Never overwrite a name being typed. + if ( ! $name.is(':focus') ) $name.val(groupLabel(group)); + const $grid = $overlay.find('.myapps-group-panel-grid'); + // Only as wide as it needs to be, within the cap the stylesheet sets + // for the viewport — a folder of three in a four-wide card would sit + // lopsided against a stretch of empty surface. + const maxCols = parseInt(getComputedStyle($grid[0]).getPropertyValue('--myapps-group-cols-max'), 10); + $grid[0].style.setProperty( + '--myapps-group-cols', + String(Math.max(1, Math.min(apps.length, Number.isFinite(maxCols) ? maxCols : 4))), + ); + const html = apps.map(app => buildTileHtml(app)).join(''); + // A rebuild replaces every tile node, and a node detached mid-gesture + // takes the rest of that gesture with it: the name box commits on + // BLUR, which fires on the press — before the click it belongs to — + // so renaming a folder and then clicking an app in it renamed the + // folder and did nothing else (the click found no tile to bubble + // from). Nothing here changes on a rename, so nothing is rebuilt. + if ( $grid[0].__myappsPanelHtml !== html ) { + // The rebuild is about to detach a focused tile, and the routes + // in here that go through a context menu (Remove from Folder) + // already dropped focus to when the menu closed. Focus + // stranded outside an open folder breaks its modality — Tab + // walks the inert grid behind the scrim — so pull it back to + // the same app's tile (or the first). Only from : an + // uninstall modal above the folder holds focus legitimately. + const focusedApp = $grid[0].contains(document.activeElement) + ? document.activeElement.dataset.appName + : null; + $grid[0].__myappsPanelHtml = html; + $grid.html(html); + $overlay.find('.myapps-tile').attr('tabindex', '0'); + const ae = document.activeElement; + if ( ae === document.body || ae === null || ! ae.isConnected ) { + const tiles = $grid[0].querySelectorAll('.myapps-tile'); + const target = [...tiles].find(t => t.dataset.appName === focusedApp) || tiles[0]; + if ( target ) target.focus({ preventScroll: true }); + } + } else { + // Tiles that survive keep the resting rects an earlier drag left + // on them, and the card may have moved since (a resize re-centres + // it) — stale rects are what the next drag would hit-test against. + for ( const el of $grid[0].children ) el.__myappsRestRect = null; + } + this.updateRunningDots($el_window); + }, + + _animateGroupPanelOpen ($el_window, fromTile) { + const $overlay = $el_window.find('.myapps-group-overlay'); + const panel = $overlay.find('.myapps-group-panel')[0]; + const icon = fromTile && fromTile.isConnected + ? (fromTile.querySelector('.myapps-tile-icon') || fromTile) + : null; + const from = icon ? icon.getBoundingClientRect() : null; + const to = panel.getBoundingClientRect(); + + if ( this._reduceMotion() || ! from || from.width <= 0 || to.width <= 0 ) { + $overlay.addClass('myapps-group-open'); + return; + } + + // Start collapsed onto the folder's own icon, then release: the card + // is the icon, enlarged. + const scale = Math.max(0.05, from.width / to.width); + panel.style.transition = 'none'; + panel.style.transform = + `translate(${from.left + from.width / 2 - (to.left + to.width / 2)}px, ` + + `${from.top + from.height / 2 - (to.top + to.height / 2)}px) scale(${scale})`; + void panel.offsetWidth; // commit the collapsed start state + panel.style.transition = ''; + $overlay.addClass('myapps-group-open'); + panel.style.transform = ''; + }, + + _closeGroup ($el_window, { instant = false } = {}) { + const groupId = this._openGroupId; + if ( ! groupId ) return; + this._openGroupId = null; + // Commit a name still being typed. Every other way out of the folder + // (clicking outside it, launching an app from it) commits through the + // box's own blur while the folder is still open; closing is the one + // path that blurs it AFTER — handing focus back to the tile below, or + // simply removing the card — so the blur handler would find no open + // folder to rename and the typed name would be dropped on the floor. + const nameEl = $el_window.find('.myapps-group-name')[0]; + if ( nameEl && document.activeElement === nameEl ) { + this._renameGroup($el_window, groupId, nameEl.value); + } + clearTimeout(this._createdGroupTimer); + if ( this._groupEscHandler ) { + document.removeEventListener('keydown', this._groupEscHandler); + this._groupEscHandler = null; + } + + const $overlay = $el_window.find('.myapps-group-overlay'); + const returnFocus = this._groupReturnFocus; + this._groupReturnFocus = null; + + // Hand focus back to the tile the folder came from — but only if it is + // still on screen and the user hasn't moved on to something else. + const tile = $el_window.find(`.myapps-group-tile[data-group-id="${CSS.escape(groupId)}"]`)[0] + || (returnFocus && returnFocus.isConnected ? returnFocus : null); + if ( tile && $overlay[0] && $overlay[0].contains(document.activeElement) ) { + tile.focus({ preventScroll: true }); + } + + if ( $overlay.length === 0 ) return; + if ( instant || this._reduceMotion() ) { + $overlay.remove(); + return; + } + + // The card is on its way out but its scrim still spans the viewport: + // without this it would swallow every click on the grid until the + // removal below, so the tile a user reaches for the instant a folder + // shuts would do nothing. + $overlay.css('pointer-events', 'none'); + + // Back into the tile it grew out of; if that tile is gone (the folder + // dissolved, the page flipped) the card simply recedes where it is. + const panel = $overlay.find('.myapps-group-panel')[0]; + const icon = tile ? (tile.querySelector('.myapps-tile-icon') || tile) : null; + const from = icon ? icon.getBoundingClientRect() : null; + const to = panel.getBoundingClientRect(); + if ( from && from.width > 0 && to.width > 0 ) { + const scale = Math.max(0.05, from.width / to.width); + panel.style.transform = + `translate(${from.left + from.width / 2 - (to.left + to.width / 2)}px, ` + + `${from.top + from.height / 2 - (to.top + to.height / 2)}px) scale(${scale})`; + } + $overlay.removeClass('myapps-group-open'); + setTimeout(() => $overlay.remove(), GROUP_PANEL_CLOSE_MS); + }, + + _renameGroup ($el_window, groupId, name) { + const group = findGroupById(this._groups, groupId); + if ( ! group ) return; + const next = renameGroup(this._groups, groupId, name); + const renamed = findGroupById(next, groupId); + // An empty or whitespace-only name keeps the old one; put it back in + // the box so what the user sees is what is stored. + $el_window.find('.myapps-group-name').val(renamed ? groupLabel(renamed) : ''); + if ( ! renamed || renamed.name === group.name ) return; + this._groups = next; + this.saveGroups(); + this.renderApps($el_window, { preservePage: true, instant: true }); + }, + + // Dissolve a folder: its apps stay exactly where the folder was, side by + // side, so nothing is lost or moved somewhere the user has to find. + _ungroup ($el_window, groupId) { + if ( ! findGroupById(this._groups, groupId) ) return; + if ( this._openGroupId === groupId ) this._closeGroup($el_window); + this._groups = removeGroup(this._groups, groupId); + this.saveGroups(); + this.renderApps($el_window, { preservePage: true, instant: true }); + }, + + // Take one app out of a folder. It lands on the grid immediately after + // the folder it came from, so it turns up where the user was looking + // rather than at the end of the last page. Returns whether it moved. + _ejectApp ($el_window, groupId, appName) { + const group = findGroupById(this._groups, groupId); + if ( ! group || ! group.apps.includes(appName) ) return false; + + const anchors = group.apps.filter(name => name !== appName); + const names = orderWithAppAfter(this._gridOrderNames($el_window), appName, anchors); + this._groups = removeAppFromGroups(this._groups, appName); + this._apps = reconcileAppOrder(this._apps, names); + this.saveGroups(); + this.saveOrder(); + return true; + }, + + // The context-menu route out of the open folder; the drag route is + // _commitEject, which always closes the folder because the user is + // already looking at the grid by then. + _ejectFromGroup ($el_window, appName) { + if ( ! this._ejectApp($el_window, this._openGroupId, appName) ) return; + // Two apps left one behind: the folder is gone and there is nothing to + // stay open for. Otherwise the folder stays open, one app lighter. + const dissolved = ! findGroupById(this._groups, this._openGroupId); + if ( dissolved ) this._closeGroup($el_window); + this.renderApps($el_window, { preservePage: true, instant: true }); + // The re-render replaced every node, including whatever _closeGroup + // just handed focus to — and the context menu this ran from dropped + // focus to anyway. Give it to the ejected app's tile, where + // the user's attention is. (The folder-stays-open case is covered by + // _refreshGroupPanel's own restore.) + if ( dissolved && (document.activeElement === document.body || ! document.activeElement) ) { + const tile = $el_window.find('.myapps-page .myapps-tile').toArray() + .find(el => el.dataset.appName === appName); + if ( tile ) tile.focus({ preventScroll: true }); + } + }, + + saveGroups () { + const groups = serializeAppGroups(this._groups); + this._groups = groups; + // Loads already in flight fetched kv before this save; mark the + // boundary so their stale snapshot can't replay over it (see + // _resolveGroups). + this._groupsSavedAtSeq = this._loadSeq || 0; + try { + const p = puter.kv.set(APP_GROUPS_KV_KEY, JSON.stringify(groups)); + if ( p && typeof p.catch === 'function' ) { + p.catch(err => console.error('Failed to save app folders:', err)); + } + } catch ( err ) { + console.error('Failed to save app folders:', err); + } + }, + // -- Drag-to-reorder -- _onTilePointerDown ($el_window, e, tileEl) { @@ -1076,6 +1674,14 @@ const TabApps = { const query = String($el_window.find('.myapps-search').val() || '').trim(); if ( query ) return; + // A drag inside an open folder rearranges (or empties) that folder; + // one on the grid rearranges the grid. The container decides which, + // and is fixed for the life of the gesture. + const panelGrid = tileEl.closest('.myapps-group-panel-grid'); + // A folder that is open but not the one being dragged in means the + // grid behind is under a backdrop the user can't reach anyway. + if ( this._openGroupId && ! panelGrid ) return; + const pointerType = oe.pointerType || 'mouse'; // Touch reorders only inside reorder mode (the button is the way in; // outside it the scroller owns touch gestures and would cancel the @@ -1086,6 +1692,8 @@ const TabApps = { const d = this._drag = { $el_window, tileEl, + panelGrid, + groupId: panelGrid ? this._openGroupId : null, pointerType, pointerId: oe.pointerId, startX: oe.clientX, @@ -1100,6 +1708,15 @@ const TabApps = { edgeDir: 0, flipping: false, flipClearTimer: null, + // Folder gestures: the tile being hovered long enough to swallow + // this one, and (inside a folder) whether the drop would take the + // app back out onto the grid. + mergeEl: null, + mergeTimer: null, + mergeArmed: false, + mergeAnchorX: 0, + mergeAnchorY: 0, + ejecting: false, }; // Ignore events from a second pointer (e.g. a stray finger) so it can't @@ -1140,12 +1757,41 @@ const TabApps = { d.lastClientX = e.clientX; d.lastClientY = e.clientY; this._positionGhost(e.clientX, e.clientY); + if ( d.panelGrid ) { + // Carrying an app past the folder's edge takes it out of the + // folder; the card visibly recoils so the intent is legible + // before the finger lifts. + this._updateEjectState(e.clientX, e.clientY); + if ( d.ejecting ) return; + this._updatePlaceholder(e.clientX, e.clientY); + return; + } if ( d.flipping ) return; this._maybeEdgeFlip(e.clientX); if ( d.flipping ) return; this._updatePlaceholder(e.clientX, e.clientY); }, + // Whether the dragged icon has left the open folder's card (with a margin + // of forgiveness, since the card's edge is also where the last row of + // tiles sits). Only meaningful for a drag that started inside a folder. + _updateEjectState (px, py) { + const d = this._drag; + const panel = d.$el_window.find('.myapps-group-panel')[0]; + if ( ! panel ) return; + const r = panel.getBoundingClientRect(); + // The dragged icon's centre, not the fingertip — the drop follows + // where the tile visually is (same probe the placeholder uses). + const x = px - d.offsetX + d.tileW / 2; + const y = py - d.offsetY + d.tileH / 2; + const outside = x < r.left - GROUP_EJECT_MARGIN || x > r.right + GROUP_EJECT_MARGIN + || y < r.top - GROUP_EJECT_MARGIN || y > r.bottom + GROUP_EJECT_MARGIN; + if ( outside === d.ejecting ) return; + d.ejecting = outside; + d.$el_window.find('.myapps-group-overlay').toggleClass('myapps-group-ejecting', outside); + if ( outside ) this._vibrate(8); + }, + _beginDrag () { const d = this._drag; if ( ! d || d.started ) return; @@ -1193,8 +1839,16 @@ const TabApps = { const r = scroller.getBoundingClientRect(); let dir = 0; - if ( px >= r.right - DRAG_EDGE_ZONE ) dir = 1; - else if ( px <= r.left + DRAG_EDGE_ZONE ) dir = -1; + // A merge offer in progress means the icon is parked on a tile, not + // asking for a page — and a last-column tile sits inside the edge + // zone, so without this hold the page would flip out from under the + // very folder the user is watching form. Carrying the icon off the + // tile withdraws the offer (see _updatePlaceholder), and with it + // this hold. + if ( ! d.mergeEl ) { + if ( px >= r.right - DRAG_EDGE_ZONE ) dir = 1; + else if ( px <= r.left + DRAG_EDGE_ZONE ) dir = -1; + } const atEnd = (dir === 1 && this._page >= this._pageCount - 1); const atStart = (dir === -1 && this._page <= 0); @@ -1212,6 +1866,10 @@ const TabApps = { d.edgeTimer = null; d.edgeDir = 0; if ( this._drag !== d ) return; + // The offer can arrive while this dwell runs (resting on an + // edge-zone tile starts both countdowns): no pointer event fires + // during a rest to clear the timer, so re-check at the flip. + if ( d.mergeEl ) return; d.flipping = true; this.goToPage(d.$el_window, this._page + dir, true); clearTimeout(d.flipClearTimer); @@ -1233,10 +1891,13 @@ const TabApps = { // between slots, and testing its live box would swap it straight back. // 2. A tile only counts as the target when the dragged icon's centre is // well inside it (DRAG_HIT_INSET), so hovering a boundary does nothing. - _updatePlaceholder (px, py) { + // + // Hovering also has a second meaning — "make a folder out of us" — which + // _considerMerge arbitrates by motion before any shuffling happens. + _updatePlaceholder (px, py, { force = false } = {}) { const d = this._drag; if ( ! d ) return; - const pageEl = d.$el_window.find('.myapps-page').toArray()[this._page]; + const pageEl = d.panelGrid || d.$el_window.find('.myapps-page').toArray()[this._page]; if ( ! pageEl ) return; // Probe with the dragged icon's centre rather than the fingertip, so the @@ -1244,8 +1905,14 @@ const TabApps = { const probeX = px - d.offsetX + d.tileW / 2; const probeY = py - d.offsetY + d.tileH / 2; + // An armed folder target holds across its whole tile: a hand that + // drifts while watching the well fill must not silently lose it. + if ( d.mergeArmed && d.mergeEl ) { + if ( this._probeOverTile(d.mergeEl, probeX, probeY, DRAG_MERGE_STICKY_PAD) ) return; + this._clearMergeTarget(); + } + const tiles = Array.from(pageEl.querySelectorAll('.myapps-tile')); - const phIndex = tiles.indexOf(d.tileEl); let overIndex = -1; for ( let i = 0; i < tiles.length; i++ ) { @@ -1260,13 +1927,36 @@ const TabApps = { break; } } + const overTile = overIndex === -1 ? null : tiles[overIndex]; + + // The icon has carried on past the tile it was hovering: that was a + // pass-through, not a folder — the tile steps aside now, which is the + // shuffle that was held back while the two readings were open. + if ( d.mergeEl && d.mergeEl !== overTile ) { + const passed = d.mergeEl; + this._clearMergeTarget(); + this._displaceTo(tiles, passed, pageEl); + return; + } // In a gap / over the placeholder itself: leave the arrangement alone. - if ( overIndex === -1 ) return; + if ( ! overTile ) return; - // Move the placeholder to that tile's slot; everything between cascades. - // After the move the probe sits over the vacated gap, so it won't bounce. - const overTile = tiles[overIndex]; + // Still deciding whether this hover is a pass-through or a folder: + // hold the shuffle until the answer is in (`force` is the drop + // resolving it — see _endDrag). + if ( ! force && this._considerMerge(overTile) ) return; + + this._displaceTo(tiles, overTile, pageEl); + }, + + // Move the placeholder into `overTile`'s slot; everything between cascades. + // After the move the probe sits over the vacated gap, so it won't bounce. + _displaceTo (tiles, overTile, pageEl) { + const d = this._drag; + const overIndex = tiles.indexOf(overTile); + if ( overIndex === -1 || ! overTile.isConnected ) return; + const phIndex = tiles.indexOf(d.tileEl); const refNode = (phIndex === -1 || overIndex < phIndex) ? overTile : overTile.nextElementSibling; @@ -1277,6 +1967,96 @@ const TabApps = { }); }, + // Offer `overTile` as a folder and start the countdown. Returns true while + // the offer stands — the caller holds the shuffle for exactly that long, + // since displacing the target would move it out from under the very icon + // deciding to join it. + _considerMerge (overTile) { + const d = this._drag; + if ( ! this._canMergeInto(overTile) ) { + this._clearMergeTarget(); + return false; + } + if ( d.mergeEl === overTile ) return true; // countdown already running + + d.mergeEl = overTile; + d.mergeAnchorX = d.lastClientX; + d.mergeAnchorY = d.lastClientY; + overTile.classList.add('myapps-tile-merge-pending'); + + // Re-arming rather than one-shot: a pointer that is still moving when + // the dwell elapses restarts it from wherever it now is, so the offer + // lands the moment the icon comes to rest and never a moment before. + // (One-shot the other way round — cancel on movement — could never + // fire at all: the last events of a drag are the ones that carry the + // icon onto the target, and nothing is dispatched while it rests.) + const tick = () => { + if ( this._drag !== d || d.mergeEl !== overTile ) return; + const moved = Math.hypot(d.lastClientX - d.mergeAnchorX, d.lastClientY - d.mergeAnchorY); + if ( moved > DRAG_MERGE_TRAVEL ) { + d.mergeAnchorX = d.lastClientX; + d.mergeAnchorY = d.lastClientY; + // The well's fill IS this countdown to the user (the CSS + // transition runs the same DRAG_MERGE_DWELL_MS) — and the + // hand that decelerates INTO a tile routinely trips this + // restart, because the anchor was pinned back at first + // contact. Refill from empty, or the well sits full while + // the countdown quietly runs again, promising a folder the + // drop wouldn't make. + overTile.classList.remove('myapps-tile-merge-pending'); + void overTile.offsetWidth; // commit the empty state + overTile.classList.add('myapps-tile-merge-pending'); + d.mergeTimer = setTimeout(tick, DRAG_MERGE_DWELL_MS); + return; + } + d.mergeArmed = true; + overTile.classList.add('myapps-tile-merge-armed'); + this._vibrate(12); + }; + d.mergeTimer = setTimeout(tick, DRAG_MERGE_DWELL_MS); + return true; + }, + + // Can the dragged tile be dropped INTO `tile`? Folders don't nest (so a + // folder is never the thing being dropped, and never gains another + // folder), and inside an open folder every tile is already together. + _canMergeInto (tile) { + const d = this._drag; + if ( ! d || ! tile || d.panelGrid ) return false; + if ( ! d.tileEl.dataset.appName ) return false; + const groupId = tile.dataset.groupId; + if ( groupId ) { + const group = findGroupById(this._groups, groupId); + return !! group && group.apps.length < MAX_GROUP_APPS; + } + return !! tile.dataset.appName; + }, + + _probeOverTile (tile, probeX, probeY, pad = 0) { + const r = tile.__myappsRestRect || tile.getBoundingClientRect(); + return probeX >= r.left - pad && probeX <= r.right + pad + && probeY >= r.top - pad && probeY <= r.bottom + pad; + }, + + _clearMergeTarget () { + const d = this._drag; + if ( ! d ) return; + clearTimeout(d.mergeTimer); + d.mergeTimer = null; + if ( d.mergeEl ) { + d.mergeEl.classList.remove('myapps-tile-merge-pending', 'myapps-tile-merge-armed'); + } + d.mergeEl = null; + d.mergeArmed = false; + }, + + // A haptic tick for the touch gestures that change what a drop will do. + _vibrate (ms) { + if ( ! this._drag || this._drag.pointerType !== 'touch' ) return; + if ( ! navigator.vibrate ) return; + try { navigator.vibrate(ms); } catch ( _e ) { /* not supported */ } + }, + // First-Last-Invert-Play, interruption-safe. Records each tile's true // resting rect (transforms cleared first) so an interrupting reorder // continues smoothly and hit-testing always reads a stable position. @@ -1328,14 +2108,27 @@ const TabApps = { window.removeEventListener('blur', d.onBlur); clearTimeout(d.edgeTimer); clearTimeout(d.flipClearTimer); + clearTimeout(d.mergeTimer); }, _endDrag (commit) { const d = this._drag; if ( ! d ) return; + const mergeEl = commit && d.mergeArmed ? d.mergeEl : null; + const ejecting = commit && d.ejecting; + // A drop that was still deciding between "pass through" and "make a + // folder" (see _considerMerge) has its answer now: it never became a + // folder, so let the held-back shuffle happen — a quick drop onto a + // neighbour reorders, exactly as it always did. + if ( commit && d.started && d.mergeEl && ! d.mergeArmed ) { + this._clearMergeTarget(); + this._updatePlaceholder(d.lastClientX, d.lastClientY, { force: true }); + } + this._clearMergeTarget(); this._drag = null; this._teardownDragListeners(d); document.body.classList.remove('myapps-reordering'); + d.$el_window.find('.myapps-group-overlay').removeClass('myapps-group-ejecting'); if ( ! d.started ) { // Never became a drag — leave the click to open the app. @@ -1349,9 +2142,14 @@ const TabApps = { this._suppressEmptyTapBriefly(); let changed = false; - if ( commit ) { - const names = d.$el_window.find('.myapps-page .myapps-tile').toArray() - .map(t => t.getAttribute('data-app-name')); + if ( mergeEl ) { + changed = this._commitMerge(d, mergeEl); + } else if ( ejecting ) { + changed = this._commitEject(d); + } else if ( commit && d.panelGrid ) { + changed = this._commitFolderOrder(d); + } else if ( commit ) { + const names = this._gridOrderNames(d.$el_window); const current = this._apps.map(a => a.name); // Only persist when the order actually changed, so a pickup // dropped back in place doesn't freeze the default ordering. @@ -1374,8 +2172,15 @@ const TabApps = { const ghost = d.ghost; if ( ghost ) { - ghost.classList.add('myapps-drag-ghost-drop'); - setTimeout(() => ghost.remove(), this._reduceMotion() ? 0 : 160); + if ( mergeEl && ! this._reduceMotion() ) { + // The icon falls INTO the folder it just joined rather than + // fading where it was dropped — the only thing on screen that + // says where the app went. + this._dropGhostInto(ghost, mergeEl, d); + } else { + ghost.classList.add('myapps-drag-ghost-drop'); + setTimeout(() => ghost.remove(), this._reduceMotion() ? 0 : 160); + } } // Rebuild so pages rebalance to exactly perPage; skip the load fade. @@ -1384,6 +2189,126 @@ const TabApps = { this._applyPendingLoad(); }, + // Fly the drag ghost into a folder tile's icon and let it shrink away + // there. Purely decorative: the grid underneath has already been rebuilt. + _dropGhostInto (ghost, targetTile, d) { + const icon = targetTile.querySelector('.myapps-tile-icon') || targetTile; + const to = icon.getBoundingClientRect(); + if ( to.width <= 0 ) { + ghost.remove(); + return; + } + const scale = Math.max(0.1, to.width / Math.max(1, d.tileW)); + ghost.style.transformOrigin = 'center'; + ghost.style.transition = `transform ${DRAG_MERGE_DROP_MS}ms cubic-bezier(0.32, 0.72, 0, 1), opacity ${DRAG_MERGE_DROP_MS}ms ease-in`; + ghost.style.transform = + `translate(${to.left + to.width / 2 - d.tileW / 2}px, ${to.top + to.height / 2 - d.tileH / 2}px) scale(${scale})`; + ghost.style.opacity = '0'; + setTimeout(() => ghost.remove(), DRAG_MERGE_DROP_MS + 40); + }, + + // An app was dropped onto another app (make a folder of the two) or onto a + // folder (join it). The dropped app moves next to what it joined in the + // flat order, which is what puts the folder in the target's slot when the + // grid is rebuilt — see buildGridItems. Returns whether anything changed. + _commitMerge (d, targetTile) { + const appName = d.tileEl.dataset.appName; + if ( ! appName ) return false; + + // Read the order BEFORE the folders change: the group tiles still + // expand to what they held when the DOM was built. + let names = this._gridOrderNames(d.$el_window); + const targetGroupId = targetTile.dataset.groupId; + let createdId = null; + + if ( targetGroupId ) { + const target = findGroupById(this._groups, targetGroupId); + if ( ! target ) return false; + this._groups = addAppToGroup(this._groups, targetGroupId, appName); + names = orderWithAppAfter(names, appName, target.apps); + } else { + const targetName = targetTile.dataset.appName; + if ( ! targetName || targetName === appName ) return false; + const created = createGroup( + this._groups, + [targetName, appName], + defaultGroupName(this._groups, i18n('app_group_default_name', [], false)), + ); + if ( ! created.id ) return false; + this._groups = created.groups; + createdId = created.id; + names = orderWithAppAfter(names, appName, [targetName]); + } + + this._apps = reconcileAppOrder(this._apps, names); + this.saveGroups(); + this.saveOrder(); + + // A folder the user just invented opens itself: it shows what the drop + // made, and lands on the name so "Folder" doesn't have to stick. + if ( createdId ) { + const $el_window = d.$el_window; + clearTimeout(this._createdGroupTimer); + this._createdGroupTimer = setTimeout(() => { + // Not over a drag the user has since started, and not onto a + // tab they have since left — either way the moment has passed. + if ( this._drag ) return; + if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return; + const tile = $el_window.find(`.myapps-group-tile[data-group-id="${CSS.escape(createdId)}"]`)[0]; + this._openGroup($el_window, createdId, tile, { editName: true }); + }, this._reduceMotion() ? 0 : GROUP_CREATE_OPEN_DELAY_MS); + } + return true; + }, + + // An app was carried out of the open folder: it leaves the folder and + // lands beside it on the grid, and the folder closes behind it (there is + // nothing left to say — the user is looking at the grid now). + _commitEject (d) { + if ( ! this._ejectApp(d.$el_window, d.groupId, d.tileEl.dataset.appName) ) return false; + this._closeGroup(d.$el_window); + return true; + }, + + // A drag that stayed inside the folder rearranged it. The flat app order + // is rewritten to match so the folder's contents and the grid's order + // never disagree about who comes first. + _commitFolderOrder (d) { + const group = findGroupById(this._groups, d.groupId); + if ( ! group ) return false; + const tiles = d.panelGrid.querySelectorAll('.myapps-tile'); + const ordered = Array.from(tiles, tile => tile.dataset.appName).filter(Boolean); + const before = group.apps.join('\n'); + this._groups = reorderGroupApps(this._groups, d.groupId, ordered); + const after = (findGroupById(this._groups, d.groupId) || { apps: [] }).apps.join('\n'); + if ( before === after ) return false; + + this._apps = reconcileAppOrder( + this._apps, + flattenGridItems(buildGridItems(this._apps, this._groups)).map(app => app.name), + ); + this.saveGroups(); + this.saveOrder(); + return true; + }, + + // The grid's app order as the DOM currently shows it, folders expanded to + // the apps they hold. This is the same flat shape the saved order stores, + // so a folder is just a run of adjacent names in it. + _gridOrderNames ($el_window) { + const present = new Set(this._apps.map(a => a.name)); + const names = []; + for ( const tile of $el_window.find('.myapps-page .myapps-tile').toArray() ) { + if ( tile.dataset.groupId ) { + const group = findGroupById(this._groups, tile.dataset.groupId); + if ( group ) names.push(...group.apps.filter(name => present.has(name))); + continue; + } + if ( tile.dataset.appName ) names.push(tile.dataset.appName); + } + return names; + }, + // A load that resolved mid-drag was stashed rather than rendered (see // _fetchAndRenderApps). Apply it now; _resolveOrderNames picks between // the canonical in-memory saved order and the kv snapshot this load @@ -1397,11 +2322,19 @@ const TabApps = { const orderedNames = this._resolveOrderNames(pending.loadSeq, pending.orderedNames); this._savedOrderNames = orderedNames; + this._groups = this._resolveGroups(pending.loadSeq, pending.groups); this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0; this._apps = reconcileAppOrder(pending.merged, orderedNames); this.renderApps(pending.$el_window, { preservePage: true, instant: true }); }, + // The folders half of _resolveOrderNames, for the same reason: a fetch + // issued before the user's latest local folder edit carries a pre-edit kv + // snapshot, and replaying it would visibly undo the folder they just made. + _resolveGroups (loadSeq, fetchedGroups) { + return loadSeq <= (this._groupsSavedAtSeq || 0) ? this._groups : fetchedGroups; + }, + // Decide which saved-order snapshot a resolving load reconciles against. // A fetch issued before the user's latest local order save carries a // pre-save kv snapshot — replaying it would visibly revert the reorder, @@ -1552,7 +2485,7 @@ const TabApps = { return { apps: all, complete: true }; }; - const [installedResult, launchRes, savedOrderRaw, removedAppsRaw] = await Promise.all([ + const [installedResult, launchRes, savedOrderRaw, removedAppsRaw, groupsRaw] = await Promise.all([ fetchAllInstalledApps(), fetch( `${window.api_origin}/get-launch-apps?icon_size=128`, @@ -1563,6 +2496,7 @@ const TabApps = { ), puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => null), + puter.kv.get(APP_GROUPS_KV_KEY).catch(() => null), ]); const installedApps = installedResult.apps; @@ -1645,6 +2579,8 @@ const TabApps = { } catch ( _e ) { orderedNames = null; } + const groups = parseAppGroups(groupsRaw); + // Skip only if a strictly newer load already applied its result. if ( loadSeq < (this._appliedSeq || 0) ) return; // A drag began while we were awaiting: rendering now would yank @@ -1652,7 +2588,7 @@ const TabApps = { // away either — stash it for _endDrag to apply. if ( this._drag?.started ) { if ( ! this._pendingLoad || loadSeq > this._pendingLoad.loadSeq ) { - this._pendingLoad = { $el_window, merged, orderedNames, loadSeq }; + this._pendingLoad = { $el_window, merged, orderedNames, groups, loadSeq }; } return; } @@ -1663,6 +2599,7 @@ const TabApps = { // replayed over by this load's pre-save kv snapshot. const effectiveOrder = this._resolveOrderNames(loadSeq, orderedNames); this._savedOrderNames = effectiveOrder; + this._groups = this._resolveGroups(loadSeq, groups); this._hasCustomOrder = Array.isArray(effectiveOrder) && effectiveOrder.length > 0; @@ -1679,6 +2616,14 @@ const TabApps = { }, onActivate ($el_window) { + // A folder is modal over THIS tab's grid, and the dashboard only hides + // an inactive section — it never tells a tab it was left. So one open + // when the user walked off to Files is still open, mid-browse, when + // they come back, with focusSearch about to drop the caret in the + // search box behind its scrim: typing would then filter a grid the + // folder is covering. Coming back to the tab is coming back to the + // grid. + this._closeGroup($el_window, { instant: true }); this.loadApps($el_window); this.focusSearch($el_window); }, @@ -1800,9 +2745,12 @@ const TabApps = { // renderApps defers to the ResizeObserver until the // container has a size). Scoped to the ACTIVE section: if // the user has already moved to another tab, there is no - // visible tile to introduce from. - const candidate = $el_window.find('.dashboard-section-apps.active .myapps-tile').toArray() - .find(el => el.dataset.appName === appName); + // visible tile to introduce from. An app inside a folder has + // no tile of its own — the folder's tile stands in, and the + // wayfinding below opens it. + const tiles = $el_window.find('.dashboard-section-apps.active .myapps-tile').toArray(); + const candidate = tiles.find(el => el.dataset.appName === appName) + || tiles.find(el => el.dataset.groupId && parseTileGroupApps(el).includes(appName)); if ( candidate ) { const revealed = ! $el_window.find('.myapps-pager').hasClass('myapps-pager-loading'); const img = candidate.querySelector('.myapps-tile-icon img'); @@ -1841,6 +2789,20 @@ const TabApps = { await sleep(DEEP_LINK_INTRO_FLIP_SETTLE_MS); if ( interrupted || document.visibilityState === 'hidden' ) return tile; } + // The app lives in a folder: open it, so the launch grows out of + // the icon where the app actually is — and the user learns where + // to find it again. settleDeepLinkLaunch shuts it afterwards. + if ( tile.dataset.groupId ) { + this._openGroup($el_window, tile.dataset.groupId, tile); + this._deepLinkFolder = $el_window; + await sleep(GROUP_PANEL_OPEN_MS); + const inFolder = $el_window.find('.myapps-group-panel-grid .myapps-tile').toArray() + .find(el => el.dataset.appName === appName); + // A folder that refused to open (its apps went missing under + // us) leaves the folder's own tile as the morph's anchor. + if ( inFolder ) tile = inFolder; + if ( interrupted || document.visibilityState === 'hidden' ) return tile; + } begin_dashboard_tile_launch(tile); flourish_played = true; if ( teach ) { @@ -1884,6 +2846,12 @@ const TabApps = { settleDeepLinkLaunch (appName, tile) { this._launchingApps.delete(appName); settle_dashboard_tile_launch(tile); + // A folder the intro opened to show where the app lives has done its + // job once the window is up (or the launch has failed). + if ( this._deepLinkFolder ) { + this._closeGroup(this._deepLinkFolder); + this._deepLinkFolder = null; + } }, }; diff --git a/src/gui/src/UI/Dashboard/appGroups.js b/src/gui/src/UI/Dashboard/appGroups.js new file mode 100644 index 000000000..2a2db9a7b --- /dev/null +++ b/src/gui/src/UI/Dashboard/appGroups.js @@ -0,0 +1,416 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * The My Apps folder model. A folder is a named set of app names; the grid's + * left-to-right ORDER still lives entirely in the saved app order (see + * appOrder.js) — a folder occupies the slot of its first member, and its + * members sit contiguously in that flat order. Keeping the two records + * orthogonal means an app that is temporarily missing (an installedApps page + * that failed to load) keeps both its folder and its position, and every + * existing saved order stays valid without migration. + */ + +/** kv key under which the user's My Apps folders are stored. */ +export const APP_GROUPS_KV_KEY = 'dashboard_app_groups'; + +/** Longest folder name that is stored; longer input is clipped. */ +export const MAX_GROUP_NAME_LENGTH = 40; + +/** Sanity caps, so a corrupt (or hostile) kv value can't wedge the grid. */ +export const MAX_GROUPS = 100; +export const MAX_GROUP_APPS = 100; + +/** + * @typedef {{ id: string, name: string, apps: string[] }} AppGroup + * @typedef {{ type: 'app', app: object } | { type: 'group', group: AppGroup, apps: object[] }} GridItem + */ + +/** + * Trim a folder name to what is worth storing: whitespace collapsed, clipped + * to {@link MAX_GROUP_NAME_LENGTH}. Anything unusable becomes '' — callers + * decide whether that means "keep the old name" (rename) or "use the default" + * (creation). + * + * @param {unknown} name + * @returns {string} + */ +export function normalizeGroupName (name) { + if ( typeof name !== 'string' ) return ''; + return name.replace(/\s+/g, ' ').trim().slice(0, MAX_GROUP_NAME_LENGTH); +} + +/** + * Parse the persisted folders value. Tolerates every shape kv can hand back + * (a JSON string, an already-deserialized array, null for "never saved") and + * any corruption inside it. Two invariants are enforced here rather than at + * every call site: an app belongs to at most one folder (first claim wins), + * and a folder always has at least two members — a folder of one is strictly + * worse than a plain tile, so it is dropped and its member becomes loose. + * Corrupt input degrades to "no folders", never to a broken Apps tab. + * + * @param {unknown} raw - value returned by `puter.kv.get` + * @returns {AppGroup[]} + */ +export function parseAppGroups (raw) { + let list = raw; + if ( typeof raw === 'string' ) { + try { + list = JSON.parse(raw); + } catch ( _e ) { + return []; + } + } + if ( ! Array.isArray(list) ) return []; + + const out = []; + const seenIds = new Set(); + const claimed = new Set(); + for ( const entry of list ) { + if ( ! entry || typeof entry !== 'object' ) continue; + const id = typeof entry.id === 'string' ? entry.id : ''; + if ( ! id || seenIds.has(id) ) continue; + + const apps = []; + if ( Array.isArray(entry.apps) ) { + for ( const name of entry.apps ) { + if ( typeof name !== 'string' || name.length === 0 ) continue; + if ( claimed.has(name) ) continue; + apps.push(name); + claimed.add(name); + if ( apps.length >= MAX_GROUP_APPS ) break; + } + } + if ( apps.length < 2 ) { + // Release the names so a later, well-formed folder can claim them. + for ( const name of apps ) claimed.delete(name); + continue; + } + + seenIds.add(id); + out.push({ id, name: normalizeGroupName(entry.name), apps }); + if ( out.length >= MAX_GROUPS ) break; + } + return out; +} + +/** + * Serialize folders to the persisted shape. Runs the value back through + * {@link parseAppGroups} so the read and write shapes stay in lockstep and a + * folder that an edit emptied out can never be written. + * + * @param {AppGroup[]} groups + * @returns {AppGroup[]} + */ +export function serializeAppGroups (groups) { + return parseAppGroups(Array.isArray(groups) ? groups : []); +} + +/** + * An id for a new folder. Folders are stored as one kv value that is written + * whole, so two devices creating a folder at the same moment already resolve + * last-write-wins; the id only has to be unique enough that a surviving + * record never collides with one made elsewhere. + * + * @returns {string} + */ +export function makeGroupId () { + return `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * A default name for a new folder: `base`, then `base 2`, `base 3`, … so two + * folders are never named the same thing. The base is passed in (rather than + * read from i18n here) to keep this module free of UI dependencies. + * + * @param {AppGroup[]} groups + * @param {string} base + * @returns {string} + */ +export function defaultGroupName (groups, base) { + const taken = new Set( + (Array.isArray(groups) ? groups : []).map(g => g && g.name), + ); + if ( ! taken.has(base) ) return base; + for ( let n = 2; n < MAX_GROUPS + 2; n++ ) { + const candidate = `${base} ${n}`; + if ( ! taken.has(candidate) ) return candidate; + } + return base; +} + +/** + * The folder holding `appName`, or null when the app is loose. + * + * @param {AppGroup[]} groups + * @param {string} appName + * @returns {AppGroup|null} + */ +export function findGroupOfApp (groups, appName) { + if ( ! Array.isArray(groups) || typeof appName !== 'string' ) return null; + for ( const g of groups ) { + if ( g && Array.isArray(g.apps) && g.apps.includes(appName) ) return g; + } + return null; +} + +/** + * Fold an ordered app list into the items the grid actually renders: loose + * apps stay as they are, and each folder is emitted once, at the slot of its + * first present member, carrying its members in the folder's own order. + * + * A folder whose members mostly failed to load renders as whatever it has: + * with fewer than two present members its member is drawn as a plain tile and + * the record is left untouched — the same "stale names are ignored, never + * destroyed" rule reconcileAppOrder follows, so a flaky page of installedApps + * can't dissolve a folder. + * + * @param {Array<{name: string}>} apps - apps in grid order + * @param {AppGroup[]} groups + * @returns {GridItem[]} + */ +export function buildGridItems (apps, groups) { + if ( ! Array.isArray(apps) ) return []; + const list = Array.isArray(groups) ? groups : []; + if ( list.length === 0 ) return apps.map(app => ({ type: 'app', app })); + + const owner = new Map(); + for ( const g of list ) { + if ( ! g || ! Array.isArray(g.apps) ) continue; + for ( const name of g.apps ) { + if ( ! owner.has(name) ) owner.set(name, g); + } + } + + // Members present in `apps`, in the folder's own order. + const members = new Map(); + for ( const app of apps ) { + const g = owner.get(app && app.name); + if ( ! g ) continue; + if ( ! members.has(g.id) ) members.set(g.id, []); + members.get(g.id).push(app); + } + for ( const g of list ) { + const present = members.get(g.id); + if ( ! present || present.length < 2 ) continue; + const rank = new Map(g.apps.map((name, i) => [name, i])); + present.sort((a, b) => rank.get(a.name) - rank.get(b.name)); + } + + const emitted = new Set(); + const items = []; + for ( const app of apps ) { + const g = owner.get(app && app.name); + const present = g ? (members.get(g.id) || []) : []; + if ( ! g || present.length < 2 ) { + items.push({ type: 'app', app }); + continue; + } + if ( emitted.has(g.id) ) continue; + emitted.add(g.id); + items.push({ type: 'group', group: g, apps: present }); + } + return items; +} + +/** + * The apps behind {@link buildGridItems}' output, flattened back to a single + * ordered list — folder members contiguous, in folder order. This is the + * shape the saved app order wants, so a folder edit and a drag both persist + * through the same path. + * + * @param {GridItem[]} items + * @returns {object[]} + */ +export function flattenGridItems (items) { + const out = []; + if ( ! Array.isArray(items) ) return out; + for ( const item of items ) { + if ( ! item ) continue; + if ( item.type === 'group' ) out.push(...(item.apps || [])); + else if ( item.app ) out.push(item.app); + } + return out; +} + +/** + * Move `movedName` to sit immediately after the last of `anchorNames` in a + * flat order — how a drop into a folder places the app beside the rest of + * that folder's members, and how ejecting one places it beside the folder it + * came out of. With no anchor present the name goes to the tail rather than + * jumping the queue at the front. The input array is not mutated. + * + * @param {string[]} names + * @param {string} movedName + * @param {string[]} anchorNames + * @returns {string[]} + */ +export function orderWithAppAfter (names, movedName, anchorNames) { + const out = (Array.isArray(names) ? names : []).filter(name => name !== movedName); + const anchors = new Set(Array.isArray(anchorNames) ? anchorNames : []); + let at = -1; + for ( let i = 0; i < out.length; i++ ) { + if ( anchors.has(out[i]) ) at = i; + } + if ( at === -1 ) out.push(movedName); + else out.splice(at + 1, 0, movedName); + return out; +} + +/** + * A new folder holding `appNames`, replacing any folder membership those apps + * already had. Returns the new folder list and the new folder's id; a folder + * left with fewer than two members by the move dissolves (serializeAppGroups + * enforces it). Returns `{ groups, id: null }` unchanged when there aren't two + * distinct apps to put in it. + * + * @param {AppGroup[]} groups + * @param {string[]} appNames + * @param {string} name + * @returns {{ groups: AppGroup[], id: string|null }} + */ +export function createGroup (groups, appNames, name) { + const members = []; + for ( const appName of (Array.isArray(appNames) ? appNames : []) ) { + if ( typeof appName !== 'string' || appName.length === 0 ) continue; + if ( ! members.includes(appName) ) members.push(appName); + } + if ( members.length < 2 ) { + return { groups: serializeAppGroups(groups), id: null }; + } + + const id = makeGroupId(); + const stripped = withoutApps(groups, members); + return { + groups: serializeAppGroups([ + ...stripped, + { id, name: normalizeGroupName(name), apps: members.slice(0, MAX_GROUP_APPS) }, + ]), + id, + }; +} + +/** + * Add `appName` to the folder `groupId` (at the end, where a drop lands), + * taking it out of whatever folder it was in. A no-op when the folder is + * gone or full. + * + * @param {AppGroup[]} groups + * @param {string} groupId + * @param {string} appName + * @returns {AppGroup[]} + */ +export function addAppToGroup (groups, groupId, appName) { + const target = findGroupById(groups, groupId); + if ( ! target || typeof appName !== 'string' || appName.length === 0 ) { + return serializeAppGroups(groups); + } + if ( target.apps.includes(appName) ) return serializeAppGroups(groups); + if ( target.apps.length >= MAX_GROUP_APPS ) return serializeAppGroups(groups); + + return serializeAppGroups(withoutApps(groups, [appName]).map(g => ( + g.id === groupId ? { ...g, apps: [...g.apps, appName] } : g + ))); +} + +/** + * Take `appName` out of every folder. The folder it leaves dissolves if that + * empties it below two members. + * + * @param {AppGroup[]} groups + * @param {string} appName + * @returns {AppGroup[]} + */ +export function removeAppFromGroups (groups, appName) { + return serializeAppGroups(withoutApps(groups, [appName])); +} + +/** + * Dissolve a folder; its members become loose tiles where the folder stood. + * + * @param {AppGroup[]} groups + * @param {string} groupId + * @returns {AppGroup[]} + */ +export function removeGroup (groups, groupId) { + return serializeAppGroups( + (Array.isArray(groups) ? groups : []).filter(g => g && g.id !== groupId), + ); +} + +/** + * Rename a folder. An unusable name (empty, whitespace only) leaves the + * existing one alone — a nameless folder is a folder the user can't tell + * apart from the next one. + * + * @param {AppGroup[]} groups + * @param {string} groupId + * @param {string} name + * @returns {AppGroup[]} + */ +export function renameGroup (groups, groupId, name) { + const clean = normalizeGroupName(name); + if ( ! clean ) return serializeAppGroups(groups); + return serializeAppGroups((Array.isArray(groups) ? groups : []).map(g => ( + g && g.id === groupId ? { ...g, name: clean } : g + ))); +} + +/** + * Re-order a folder's members. Names not in `appNames` (members that weren't + * on screen to be dragged) keep their relative order at the tail, so + * reordering what you can see never drops what you can't. + * + * @param {AppGroup[]} groups + * @param {string} groupId + * @param {string[]} appNames + * @returns {AppGroup[]} + */ +export function reorderGroupApps (groups, groupId, appNames) { + const target = findGroupById(groups, groupId); + if ( ! target ) return serializeAppGroups(groups); + + const wanted = (Array.isArray(appNames) ? appNames : []) + .filter(name => target.apps.includes(name)); + const seen = new Set(wanted); + const apps = [...new Set(wanted), ...target.apps.filter(name => ! seen.has(name))]; + + return serializeAppGroups((Array.isArray(groups) ? groups : []).map(g => ( + g && g.id === groupId ? { ...g, apps } : g + ))); +} + +/** + * @param {AppGroup[]} groups + * @param {string} groupId + * @returns {AppGroup|null} + */ +export function findGroupById (groups, groupId) { + if ( ! Array.isArray(groups) || typeof groupId !== 'string' ) return null; + return groups.find(g => g && g.id === groupId && Array.isArray(g.apps)) || null; +} + +/** Every folder with `names` removed from it; folders are left unsealed (a + * caller's serializeAppGroups drops any that fell below two members). */ +function withoutApps (groups, names) { + const drop = new Set(names); + return (Array.isArray(groups) ? groups : []) + .filter(g => g && Array.isArray(g.apps)) + .map(g => ({ ...g, apps: g.apps.filter(name => ! drop.has(name)) })); +} diff --git a/src/gui/src/UI/Dashboard/appGroups.test.js b/src/gui/src/UI/Dashboard/appGroups.test.js new file mode 100644 index 000000000..64245c572 --- /dev/null +++ b/src/gui/src/UI/Dashboard/appGroups.test.js @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { + parseAppGroups, + serializeAppGroups, + normalizeGroupName, + defaultGroupName, + findGroupOfApp, + findGroupById, + buildGridItems, + flattenGridItems, + orderWithAppAfter, + createGroup, + addAppToGroup, + removeAppFromGroups, + removeGroup, + renameGroup, + reorderGroupApps, + MAX_GROUP_NAME_LENGTH, +} from './appGroups.js'; +import { reconcileAppOrder, serializeAppOrder } from './appOrder.js'; + +const mk = (...ns) => ns.map(n => ({ name: n })); +const names = apps => apps.map(a => a.name); +const group = (id, name, ...apps) => ({ id, name, apps }); +// The grid as the user reads it: 'a' for a loose app, '[Work: a, b]' for a folder. +const shape = items => items.map(item => ( + item.type === 'group' + ? `[${item.group.name}: ${names(item.apps).join(', ')}]` + : item.app.name +)); + +describe('normalizeGroupName', () => { + it('collapses whitespace and trims', () => { + expect(normalizeGroupName(' Work Stuff \n')).toBe('Work Stuff'); + }); + + it('clips to the stored maximum', () => { + expect(normalizeGroupName('x'.repeat(200))).toHaveLength(MAX_GROUP_NAME_LENGTH); + }); + + it('returns an empty string for anything unusable', () => { + expect(normalizeGroupName(' ')).toBe(''); + expect(normalizeGroupName(null)).toBe(''); + expect(normalizeGroupName(42)).toBe(''); + }); +}); + +describe('parseAppGroups', () => { + it('parses a JSON string and an already-deserialized array alike', () => { + const raw = [group('g1', 'Work', 'a', 'b')]; + expect(parseAppGroups(JSON.stringify(raw))).toEqual(raw); + expect(parseAppGroups(raw)).toEqual(raw); + }); + + it('returns no folders for never-saved or corrupt values', () => { + expect(parseAppGroups(null)).toEqual([]); + expect(parseAppGroups('not json')).toEqual([]); + expect(parseAppGroups('{"nope":1}')).toEqual([]); + expect(parseAppGroups(7)).toEqual([]); + }); + + it('drops folders of fewer than two apps', () => { + // A folder of one is strictly worse than a plain tile. + const out = parseAppGroups([group('g1', 'Solo', 'a'), group('g2', 'Pair', 'b', 'c')]); + expect(out.map(g => g.id)).toEqual(['g2']); + }); + + it('gives an app claimed by two folders to the first, and drops the loser if that empties it', () => { + const out = parseAppGroups([ + group('g1', 'First', 'a', 'b'), + group('g2', 'Second', 'a', 'b'), + group('g3', 'Third', 'a', 'c', 'd'), + ]); + expect(out.map(g => g.id)).toEqual(['g1', 'g3']); + expect(out[1].apps).toEqual(['c', 'd']); + }); + + it('drops entries without a usable id and de-duplicates ids', () => { + const out = parseAppGroups([ + { name: 'No id', apps: ['a', 'b'] }, + group('g1', 'Keep', 'a', 'b'), + group('g1', 'Dup id', 'c', 'd'), + ]); + expect(out).toEqual([group('g1', 'Keep', 'a', 'b')]); + }); + + it('drops non-string and duplicate member names', () => { + const out = parseAppGroups([{ id: 'g1', name: 'Work', apps: ['a', '', null, 'a', 'b', 3] }]); + expect(out[0].apps).toEqual(['a', 'b']); + }); + + it('normalizes the name', () => { + expect(parseAppGroups([{ id: 'g1', name: ' Work ', apps: ['a', 'b'] }])[0].name).toBe('Work'); + expect(parseAppGroups([{ id: 'g1', apps: ['a', 'b'] }])[0].name).toBe(''); + }); +}); + +describe('serializeAppGroups', () => { + it('round-trips through parseAppGroups', () => { + const groups = [group('g1', 'Work', 'a', 'b')]; + expect(parseAppGroups(JSON.stringify(serializeAppGroups(groups)))).toEqual(groups); + }); + + it('drops a folder an edit emptied below two members', () => { + expect(serializeAppGroups([group('g1', 'Work', 'a')])).toEqual([]); + }); +}); + +describe('defaultGroupName', () => { + it('uses the base name when it is free', () => { + expect(defaultGroupName([], 'Folder')).toBe('Folder'); + }); + + it('numbers around names already in use', () => { + const groups = [group('g1', 'Folder', 'a', 'b'), group('g2', 'Folder 2', 'c', 'd')]; + expect(defaultGroupName(groups, 'Folder')).toBe('Folder 3'); + }); +}); + +describe('findGroupOfApp / findGroupById', () => { + const groups = [group('g1', 'Work', 'a', 'b')]; + + it('finds the folder holding an app', () => { + expect(findGroupOfApp(groups, 'b').id).toBe('g1'); + expect(findGroupOfApp(groups, 'z')).toBe(null); + expect(findGroupOfApp(null, 'a')).toBe(null); + }); + + it('finds a folder by id', () => { + expect(findGroupById(groups, 'g1').name).toBe('Work'); + expect(findGroupById(groups, 'nope')).toBe(null); + }); +}); + +describe('buildGridItems', () => { + it('passes apps straight through when there are no folders', () => { + expect(shape(buildGridItems(mk('a', 'b'), []))).toEqual(['a', 'b']); + expect(shape(buildGridItems(mk('a', 'b'), null))).toEqual(['a', 'b']); + }); + + it('puts a folder in the slot of its first present member', () => { + const items = buildGridItems(mk('a', 'b', 'c', 'd'), [group('g1', 'Work', 'b', 'd')]); + expect(shape(items)).toEqual(['a', '[Work: b, d]', 'c']); + }); + + it('orders members by the folder record, not by grid order', () => { + const items = buildGridItems(mk('a', 'b', 'c'), [group('g1', 'Work', 'c', 'a')]); + expect(shape(items)).toEqual(['[Work: c, a]', 'b']); + }); + + it('renders a folder whose members mostly failed to load as a loose tile', () => { + // 'b' is missing this session; the record is untouched, but one member + // is not a folder — it draws as the plain app it is. + const items = buildGridItems(mk('a', 'c'), [group('g1', 'Work', 'a', 'b')]); + expect(shape(items)).toEqual(['a', 'c']); + }); + + it('drops nothing when every member is missing', () => { + expect(shape(buildGridItems(mk('c'), [group('g1', 'Work', 'a', 'b')]))).toEqual(['c']); + }); + + it('does not mutate the folder records it reads', () => { + const groups = [group('g1', 'Work', 'c', 'a')]; + buildGridItems(mk('a', 'b', 'c'), groups); + expect(groups[0].apps).toEqual(['c', 'a']); + }); + + it('handles non-array input defensively', () => { + expect(buildGridItems(null, [])).toEqual([]); + }); +}); + +describe('flattenGridItems', () => { + it('expands folders in place, members contiguous', () => { + const apps = mk('a', 'b', 'c', 'd'); + const items = buildGridItems(apps, [group('g1', 'Work', 'b', 'd')]); + expect(names(flattenGridItems(items))).toEqual(['a', 'b', 'd', 'c']); + }); + + it('round-trips into a saved app order that rebuilds the same grid', () => { + const apps = mk('a', 'b', 'c', 'd'); + const groups = [group('g1', 'Work', 'b', 'd')]; + const order = serializeAppOrder(flattenGridItems(buildGridItems(apps, groups))); + const rebuilt = buildGridItems(reconcileAppOrder(apps, order), groups); + expect(shape(rebuilt)).toEqual(['a', '[Work: b, d]', 'c']); + }); + + it('handles non-array input defensively', () => { + expect(flattenGridItems(null)).toEqual([]); + }); +}); + +describe('orderWithAppAfter', () => { + it('moves a name to just after the last anchor', () => { + expect(orderWithAppAfter(['a', 'b', 'c', 'd'], 'd', ['b', 'c'])).toEqual(['a', 'b', 'c', 'd']); + expect(orderWithAppAfter(['a', 'b', 'c', 'd'], 'a', ['b', 'c'])).toEqual(['b', 'c', 'a', 'd']); + }); + + it('appends when no anchor is present rather than jumping to the front', () => { + expect(orderWithAppAfter(['a', 'b'], 'a', ['zz'])).toEqual(['b', 'a']); + }); + + it('inserts a name that was not in the list at all', () => { + expect(orderWithAppAfter(['a', 'b'], 'new', ['a'])).toEqual(['a', 'new', 'b']); + }); + + it('does not mutate its input', () => { + const order = ['a', 'b', 'c']; + orderWithAppAfter(order, 'c', ['a']); + expect(order).toEqual(['a', 'b', 'c']); + }); +}); + +describe('createGroup', () => { + it('creates a folder from two apps', () => { + const { groups, id } = createGroup([], ['a', 'b'], 'Work'); + expect(id).toBeTruthy(); + expect(groups).toEqual([{ id, name: 'Work', apps: ['a', 'b'] }]); + }); + + it('takes the apps out of the folders they were in', () => { + const before = [group('g1', 'Old', 'a', 'x', 'y')]; + const { groups, id } = createGroup(before, ['a', 'b'], 'New'); + expect(groups.find(g => g.id === 'g1').apps).toEqual(['x', 'y']); + expect(groups.find(g => g.id === id).apps).toEqual(['a', 'b']); + }); + + it('dissolves a folder the move emptied below two members', () => { + const { groups, id } = createGroup([group('g1', 'Old', 'a', 'x')], ['a', 'b'], 'New'); + expect(groups.map(g => g.id)).toEqual([id]); + }); + + it('refuses to make a folder without two distinct apps', () => { + expect(createGroup([], ['a', 'a'], 'Work')).toEqual({ groups: [], id: null }); + expect(createGroup([], ['a'], 'Work')).toEqual({ groups: [], id: null }); + }); +}); + +describe('addAppToGroup', () => { + it('appends to the folder, where the drop landed', () => { + const out = addAppToGroup([group('g1', 'Work', 'a', 'b')], 'g1', 'c'); + expect(out[0].apps).toEqual(['a', 'b', 'c']); + }); + + it('moves the app out of the folder it was in', () => { + const before = [group('g1', 'Work', 'a', 'b'), group('g2', 'Play', 'c', 'd')]; + const out = addAppToGroup(before, 'g1', 'c'); + expect(out.find(g => g.id === 'g1').apps).toEqual(['a', 'b', 'c']); + // 'g2' is down to one member, so it dissolves. + expect(out.find(g => g.id === 'g2')).toBeUndefined(); + }); + + it('is a no-op for an unknown folder or an app already in it', () => { + const before = [group('g1', 'Work', 'a', 'b')]; + expect(addAppToGroup(before, 'nope', 'c')).toEqual(before); + expect(addAppToGroup(before, 'g1', 'a')).toEqual(before); + }); + + it('does not mutate its input', () => { + const before = [group('g1', 'Work', 'a', 'b')]; + addAppToGroup(before, 'g1', 'c'); + expect(before[0].apps).toEqual(['a', 'b']); + }); +}); + +describe('removeAppFromGroups / removeGroup', () => { + it('takes an app out of its folder', () => { + const out = removeAppFromGroups([group('g1', 'Work', 'a', 'b', 'c')], 'b'); + expect(out[0].apps).toEqual(['a', 'c']); + }); + + it('dissolves the folder when that leaves one member', () => { + expect(removeAppFromGroups([group('g1', 'Work', 'a', 'b')], 'b')).toEqual([]); + }); + + it('dissolves a folder outright', () => { + const before = [group('g1', 'Work', 'a', 'b'), group('g2', 'Play', 'c', 'd')]; + expect(removeGroup(before, 'g1').map(g => g.id)).toEqual(['g2']); + }); +}); + +describe('renameGroup', () => { + it('renames and normalizes', () => { + expect(renameGroup([group('g1', 'Work', 'a', 'b')], 'g1', ' Play ')[0].name).toBe('Play'); + }); + + it('keeps the old name when the new one is unusable', () => { + expect(renameGroup([group('g1', 'Work', 'a', 'b')], 'g1', ' ')[0].name).toBe('Work'); + }); +}); + +describe('reorderGroupApps', () => { + it('applies the on-screen order', () => { + const out = reorderGroupApps([group('g1', 'Work', 'a', 'b', 'c')], 'g1', ['c', 'a', 'b']); + expect(out[0].apps).toEqual(['c', 'a', 'b']); + }); + + it('keeps members that were not on screen at the tail', () => { + // 'm' did not load this session, so no drag could place it. + const out = reorderGroupApps([group('g1', 'Work', 'a', 'm', 'b')], 'g1', ['b', 'a']); + expect(out[0].apps).toEqual(['b', 'a', 'm']); + }); + + it('ignores names that are not members', () => { + const out = reorderGroupApps([group('g1', 'Work', 'a', 'b')], 'g1', ['zz', 'b', 'a']); + expect(out[0].apps).toEqual(['b', 'a']); + }); + + it('is a no-op for an unknown folder', () => { + const before = [group('g1', 'Work', 'a', 'b')]; + expect(reorderGroupApps(before, 'nope', ['b', 'a'])).toEqual(before); + }); +}); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index aad1da72d..09ce81a0e 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -4148,22 +4148,40 @@ $.fn.focusWindow = function (event) { * tab AND the tile must sit on the pager page currently in view (pages are * laid side by side in a horizontal scroller, so an off-page tile has a * rendered box the user can't see). Returns the tile element, or null. + * + * An app filed away in a folder has no tile of its own while the folder is + * shut — its FOLDER's tile stands in, so minimizing sends the window to where + * the user will actually look for the app (and where opening it from will put + * it back). See buildGroupTileHtml for the data-group-apps this reads. */ function dashboard_tile_in_view (app_name) { if ( ! app_name || typeof CSS === 'undefined' || ! CSS.escape ) return null; - const tiles = document.querySelectorAll( - `.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`, - ); - for ( const tile of tiles ) { + const in_view = tile => { const rect = tile.getBoundingClientRect(); - if ( rect.width <= 0 || rect.height <= 0 ) continue; + if ( rect.width <= 0 || rect.height <= 0 ) return false; const scroller = tile.closest('.myapps-pager-scroller'); const clip = (scroller || tile.parentElement).getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; - if ( cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom ) { - return tile; + return cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom; + }; + + const tiles = document.querySelectorAll( + `.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`, + ); + for ( const tile of tiles ) { + if ( in_view(tile) ) return tile; + } + + const folders = document.querySelectorAll('.dashboard-section-apps.active .myapps-group-tile'); + for ( const folder of folders ) { + let names; + try { + names = JSON.parse(folder.dataset.groupApps || '[]'); + } catch ( _e ) { + continue; } + if ( Array.isArray(names) && names.includes(app_name) && in_view(folder) ) return folder; } return null; } diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index d501e422c..cd4c9333d 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -112,6 +112,15 @@ --dashboard-legacy-bar-start: #dbe3ef; --dashboard-legacy-bar-mid: #c2ccdc; + + /* App folders (My Apps): the icon's container, the well that opens under a + drag about to make one, and the scrim + card of an opened folder. All + translucent, so they read as a layer over the grid rather than a slab. */ + --dashboard-folder-surface: rgba(118, 130, 152, 0.16); + --dashboard-folder-border: rgba(15, 23, 42, 0.07); + --dashboard-folder-well: rgba(118, 130, 152, 0.32); + --dashboard-folder-scrim: rgba(244, 246, 250, 0.55); + --dashboard-folder-panel: rgba(255, 255, 255, 0.78); } body { @@ -221,6 +230,12 @@ body { --dashboard-legacy-bar-start: #3f3f46; --dashboard-legacy-bar-mid: #52525b; + + --dashboard-folder-surface: rgba(255, 255, 255, 0.13); + --dashboard-folder-border: rgba(255, 255, 255, 0.09); + --dashboard-folder-well: rgba(255, 255, 255, 0.24); + --dashboard-folder-scrim: rgba(9, 9, 11, 0.55); + --dashboard-folder-panel: rgba(46, 46, 50, 0.82); } } @@ -719,6 +734,17 @@ body { height: 100%; display: flex; flex-direction: column; + /* Tile geometry, declared on the tab so BOTH the pager (which reads these + via getComputedStyle in computeLayout, inheritance included) and the + folder panel — a fixed overlay outside the container — lay tiles out to + the same measurements. */ + --myapps-tile-w: 100px; + /* icon 56 + label margin 10 + one 12px/1.3 label line (~16) */ + --myapps-tile-h: 82px; + --myapps-gap-x: 32px; + --myapps-gap-y: 32px; + --myapps-dots-h: 28px; + --myapps-icon-size: 56px; } .myapps-search-wrap { @@ -857,13 +883,8 @@ input.myapps-search:disabled { } .myapps-container { - /* Pager geometry; TabApps.js reads these to size pages. */ - --myapps-tile-w: 100px; - /* icon 56 + label margin 10 + one 12px/1.3 label line (~16) */ - --myapps-tile-h: 82px; - --myapps-gap-x: 32px; - --myapps-gap-y: 32px; - --myapps-dots-h: 28px; + /* Pager geometry is inherited from .myapps-tab; TabApps.js reads it here + (computeLayout) to size pages. */ flex: 1; min-height: 0; display: flex; @@ -1131,6 +1152,240 @@ input.myapps-search:disabled { word-break: break-word; } +/* -- App folders -- + A folder wears the same tile skeleton as an app, with its icon slot filled + by iOS's miniature 3x3 grid of what is inside. */ + +.myapps-group-icon { + /* Overrides .myapps-tile-icon's centring flexbox: the mini-grid fills the + slot rather than sitting in the middle of it. */ + display: block; + /* Stated rather than left to `.dashboard *`: the drag ghost and the launch + / minimize morph ghosts are CLONES of this box parked on , outside + that rule, where the padding below would be added to the 56px slot and + land a 66px ghost over the 56px icon it is meant to sit on. */ + box-sizing: border-box; + padding: 5px; + border-radius: 14px; + background: var(--dashboard-folder-surface); + box-shadow: inset 0 0 0 1px var(--dashboard-folder-border); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); +} + +.myapps-group-icon-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 2.5px; + width: 100%; + height: 100%; +} + +.myapps-group-icon-grid img { + /* min-* 0: grid items refuse to shrink past their intrinsic size + otherwise, and a wide icon would push the mini-grid out of the slot. */ + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 3px; +} + +/* The well a drag opens under the tile it is hovering: the folder-to-be, + growing behind the icon. Present (transparent) on every tile so it can + animate in without a reflow; only a drag ever reveals it. */ +.myapps-tile::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + width: calc(var(--myapps-icon-size, 56px) + 12px); + height: calc(var(--myapps-icon-size, 56px) + 12px); + margin-left: calc((var(--myapps-icon-size, 56px) + 12px) / -2); + border-radius: 16px; + background: var(--dashboard-folder-well); + box-shadow: inset 0 0 0 1px var(--dashboard-folder-border); + opacity: 0; + transform: scale(0.62); + pointer-events: none; +} + +/* Pending: the well fills over the dwell the drop is waiting out + (DRAG_MERGE_DWELL_MS in TabApps.js) — for the ordinary gesture (carry the + icon over, stop) the fill IS the countdown, so leaving before it completes + is an informed choice rather than a surprise. */ +body.myapps-reordering .myapps-tile-merge-pending::before { + opacity: 0.55; + transform: scale(0.9); + transition: opacity 460ms ease-out, transform 460ms ease-out; +} + +/* Armed: the drop will make (or join) a folder. Snaps open with a little + overshoot, and the icon settles into the well. */ +body.myapps-reordering .myapps-tile-merge-armed::before { + opacity: 1; + transform: scale(1); + transition: opacity 140ms ease-out, transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* animation: none — reorder mode's jiggle is an animated transform and would + otherwise win over these scales, leaving the target wobbling as if nothing + were about to happen to it. */ +body.myapps-reordering .myapps-tile.myapps-tile-merge-pending .myapps-tile-icon { + animation: none; + transform: scale(0.92); +} + +body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon { + animation: none; + transform: scale(0.78); + transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* -- Opened folder -- + The grid recedes behind a blurred scrim and the folder grows out of its own + icon into a card (see _animateGroupPanelOpen). Sits inside the Apps tab, so + it travels with the dashboard window; below the uninstall modal's z-index. */ +.myapps-group-overlay { + position: fixed; + inset: 0; + z-index: 250; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.myapps-group-backdrop { + position: absolute; + inset: 0; + background: var(--dashboard-folder-scrim); + -webkit-backdrop-filter: blur(18px) saturate(1.3); + backdrop-filter: blur(18px) saturate(1.3); + opacity: 0; + transition: opacity 0.24s ease; +} + +.myapps-group-open .myapps-group-backdrop { + opacity: 1; +} + +.myapps-group-panel { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + max-width: 100%; + padding: 22px 26px 26px; + border-radius: 28px; + background: var(--dashboard-folder-panel); + -webkit-backdrop-filter: blur(28px) saturate(1.6); + backdrop-filter: blur(28px) saturate(1.6); + box-shadow: + inset 0 0 0 1px var(--dashboard-folder-border), + 0 24px 64px var(--dashboard-shadow-medium); + opacity: 0; + transform: scale(0.92); + transform-origin: center; + /* Mirrors GROUP_PANEL_OPEN_MS / GROUP_PANEL_CLOSE_MS in TabApps.js; the + curve is the window morph's, so a folder opening and an app opening + feel like the same gesture at two scales. */ + transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.2s ease; + will-change: transform; +} + +.myapps-group-open .myapps-group-panel { + opacity: 1; + transform: none; + transition: transform 0.38s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.22s ease; +} + +/* The card's tabindex=-1 exists to catch clicks on its empty space (keeping + focus inside the modal folder, where the Tab trap can see it) — a ring + around the whole card would misread that plumbing as selection. */ +.myapps-group-panel:focus { + outline: none; +} + +/* Carrying an app past the card's edge takes it out of the folder: the card + shrinks back as if making way (see _updateEjectState). */ +.myapps-group-ejecting .myapps-group-panel { + transform: scale(0.95); + opacity: 0.75; +} + +input.myapps-group-name { + /* style.css's input[type=text] rules match this box at the SAME + specificity, so only what is spelled out here wins. width, padding and + border have to be stated (twice — the focus rule below faces + input[type=text]:focus, which sets padding 7px / border 2px): left + unstated, the name field stretches across the whole card and its box + grows 8px taller the moment it is clicked, shoving the folder's grid + down. Same trap .myapps-search documents. */ + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: auto; + max-width: 100%; + /* Hug the name (iOS sizes the field to its text): a short name in the + default ~20ch box floats in a hover pill far wider than the word, and + a long one clips while the card has room. Engines without field-sizing + keep that default box — same behavior as before, just less tailored. */ + field-sizing: content; + min-width: 90px; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + font-size: 15px; + font-weight: 600; + text-align: center; + color: var(--dashboard-text-heading); + outline: none; + transition: background 0.15s ease, border-color 0.15s ease; +} + +@media (hover: hover) { + input.myapps-group-name:hover { + background: var(--dashboard-folder-surface); + } +} + +input.myapps-group-name:focus { + padding: 4px 10px; + border-width: 1px; + border-color: var(--select-color); + background: var(--dashboard-card-background); +} + +.myapps-group-panel-grid { + /* --myapps-group-cols-max is the widest the card may get; the actual count + is set inline by _refreshGroupPanel so a folder of three doesn't sit in + the corner of a four-wide card. */ + --myapps-group-cols-max: 4; + display: grid; + grid-template-columns: repeat(var(--myapps-group-cols, 4), var(--myapps-tile-w)); + column-gap: var(--myapps-gap-x); + row-gap: 24px; + justify-content: center; + align-content: start; + /* Reorder mode's uninstall badge overhangs the top corner of every tile, + and the scroller below clips whatever leaves its padding box — the top + row's badges came out sliced flat. Hold the overhang inside the padding, + pulled straight back out again so the resting layout is unchanged. */ + padding-top: 9px; + margin-top: -9px; + /* A folder big enough to need it scrolls rather than growing past the + viewport; the row gap keeps the last row from looking cut off. */ + max-height: min(52vh, 430px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; +} + /* Uninstall confirmation modal */ .myapps-modal-overlay { position: fixed; @@ -1591,6 +1846,17 @@ body.myapps-reordering .myapps-tile { transition: none; } + /* Folders still open and the well still fills — they just do it without + the travel: the states are information, the motion is decoration. */ + .myapps-group-backdrop, + .myapps-group-panel, + .myapps-group-open .myapps-group-panel, + body.myapps-reordering .myapps-tile-merge-pending::before, + body.myapps-reordering .myapps-tile-merge-armed::before, + body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon { + transition: none; + } + .dashboard-sidebar, .dashboard-sidebar-scrim, .dashboard-sidebar-toggle { @@ -2973,13 +3239,11 @@ body.myapps-reordering .myapps-tile { .dashboard-tab-content.myapps-tab { padding-bottom: max(6px, env(safe-area-inset-bottom)); - } - - .myapps-container { --myapps-tile-w: 72px; --myapps-tile-h: 74px; --myapps-gap-x: 12px; --myapps-gap-y: 30px; + --myapps-icon-size: 52px; } /* Spread the fixed column count across the page width, iOS-style; @@ -2998,6 +3262,34 @@ body.myapps-reordering .myapps-tile { height: 52px; border-radius: 12px; } + + .myapps-group-icon { + padding: 4px; + border-radius: 12px; + } + + .myapps-group-overlay { + padding: 16px; + } + + .myapps-group-panel { + gap: 10px; + padding: 16px 18px 20px; + border-radius: 24px; + } + + .myapps-group-panel-grid { + row-gap: 18px; + max-height: 46vh; + } +} + +/* Three columns rather than four once four (4x72 + 3x12 + the card's padding) + no longer fits a phone's width. */ +@media (max-width: 400px) { + .myapps-group-panel-grid { + --myapps-group-cols-max: 3; + } } /* Desktop: Make metadata wrapper transparent */ diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index ea2587ee7..3470dbc61 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -31,6 +31,13 @@ const en = { ai_app_unavailable: 'AI app is not available. Please try again later.', all_fields_required: 'All fields are required.', allow: 'Allow', + app_group_default_name: 'Folder', + app_group_name_aria: 'Folder name', + app_group_open: 'Open', + app_group_remove_from_folder: 'Remove from Folder', + app_group_rename: 'Rename', + app_group_tile_aria: '%%, folder of %% apps', + app_group_ungroup: 'Ungroup', apply: 'Apply', ascending: 'Ascending', associated_websites: 'Associated Websites',