From 06d94534e8ae45647ca6801a204af82b7e13d70b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sat, 8 Aug 2026 00:28:03 -0700 Subject: [PATCH] feat(gui): iOS-style app folders in the My Apps grid (#3525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gui): iOS-style app folders in the My Apps grid Drag one app onto another and let it settle: a well opens under the target and the drop makes a folder of the two. Dropping onto an existing folder joins it. A folder opens by growing out of its own icon into a card over a blurred grid, where its apps can be launched, rearranged, renamed, or carried back out. Hovering a tile mid-drag means two things — "push over, I'm passing through" and "swallow me" — and the tile is barely bigger than its icon, so pixels can't separate them; motion does. The shuffle is held while the folder offer stands and fires when the icon leaves the tile, or at the drop, so a quick drop onto a neighbour still reorders exactly as it did. The offer itself re-arms rather than cancelling on movement: the last events of a drag are the ones carrying the icon onto the target and nothing is dispatched while it rests, so a cancel-on-movement dwell could never fire at all. Folders are stored in their own kv key; the grid's ORDER stays entirely in the existing saved app order, with a folder occupying its first member's slot and its members contiguous. Every saved order therefore stays valid with no migration, and an app whose installedApps page failed to load keeps both its folder and its position. Folders never nest, one that drops below two apps dissolves, and a corrupt kv value degrades to "no folders" rather than a broken tab. Elsewhere: - Search looks THROUGH folders — a match the user then has to hunt for inside one is not an answer. - Minimize morphs into the FOLDER when an app lives in one, and a /app/ landing opens the folder so the launch grows out of the icon where the app actually is. - The uninstall FLIP keyed surviving tiles by app name, which a folder tile doesn't have; it now keys by identity. New pure model in appGroups.js with tests; verified end to end in the running dashboard (create, join, open, rename, reorder, eject, ungroup, launch-from-folder) alongside plain reorder, search, and uninstall. * fix(gui): keep the folder name field from inheriting input[type=text] sizing style.css styles every input[type=text] with `width: 100%` and grows it to `padding: 7px; border: 2px` on focus. `input.myapps-group-name` matches at the same specificity, so it only wins the properties it actually declares — width was never one of them, and the focus rule declared neither padding nor border width. The name field therefore spanned the entire folder card (so its hover and focus chip read as a full-width bar rather than the name) and grew 8px taller the moment it was clicked, shoving the folder's app grid down. Spell the three out, in both the resting and the focus rule — the same trap .myapps-search already documents next door. * fix(gui): size folder icon ghosts to the icon they stand on Border-box only reaches a folder's icon through `.dashboard * { box-sizing }`, and every ghost cloned from one is appended to , outside that rule: the drag ghost, the click-time launch flourish, and the open/minimize morph ghosts all fall back to content-box, where .myapps-group-icon's 5px padding is added to the 56px slot. Each ghost rendered 66px square and 5px off, so it visibly popped at exactly the moment it was supposed to sit flush on the real icon. State box-sizing on the rule itself so a clone carries it wherever it lands. * fix(gui): stop a closing folder from swallowing the next click _closeGroup drops the open class and leaves the overlay in place for GROUP_PANEL_CLOSE_MS so the card can recede into its tile. The scrim is `position: fixed; inset: 0` and still hit-testable for that whole quarter second, so a click on the grid during it landed on the outgoing overlay — whose handler only re-runs _closeGroup, now a no-op. Shutting a folder and reaching straight for an app did nothing. Take the outgoing overlay out of hit-testing; it has no interactive job left. * fix(gui): keep a folder name typed right up to the moment it closes The name box commits on blur, and every exit that goes through a pointer blurs it while the folder is still open — so clicking outside, or launching an app from inside, keeps what was typed. Escape does not: _closeGroup clears _openGroupId first and only then moves focus to the tile below (or removes the card outright), so the blur arrives with no open folder to rename and _renameGroup drops it. A brand-new folder opens with its name selected for exactly this edit, so "type Games, press Escape" — the obvious way to dismiss a dialog — was the path most likely to lose it. Commit the pending name on the way out, before the folder id is gone. * fix(gui): close an open folder when the Apps tab is re-entered The dashboard hides an inactive section and calls onActivate on the way back in; there is no deactivate hook, so a folder left open survives the round trip and greets the user still open over a grid they walked away from. Worse, onActivate's focusSearch then lands the caret in the search box behind the folder's scrim, and typing filters the grid the card is covering — a modal with the keyboard pointed outside it. Shut the folder as the tab comes back: returning to the tab is returning to the grid. * fix(gui): move focus into a folder when it opens Opening a folder called .focus({ preventScroll: true }) on a jQuery object. jQuery's .focus() shorthand reads a lone non-function argument as event DATA and binds a handler with it, so it never moved focus: the folder opened modal over the grid with focus still on the tile behind its scrim, where Tab walked away through the inert grid instead of cycling inside the dialog — and the object it bound as a handler threw a TypeError on that tile's every subsequent focus. Focus the DOM node instead, as every other focus call in this file already does. * fix(gui): stop renaming a folder from swallowing the click that commits it The folder's name box commits on blur, and blur fires on the PRESS — before the click that press belongs to. Committing re-rendered, and the re-render replaced every tile in the open folder, so by the time the click was dispatched the tile under the pointer was detached and the delegated handler never saw it. Typing a name and then tapping an app in the folder — the path a brand-new folder puts the user on — renamed the folder and did nothing else; the app only opened on a second click. Rebuild the folder's contents only when they actually differ from what is on screen. A rename doesn't change them, so nothing is detached, and a background refresh no longer throws away hover/focus either. Tiles that survive get their drag resting-rects cleared, since the card can have moved under them since the rects were taken. * fix(gui): keep Enter in a folder's name box from leaving the folder Committing the name with Enter blurred the box, which left focus on — outside a dialog that is marked aria-modal and that traps Tab on its own subtree. The next Tab therefore walked off through the inert grid the folder is covering, exactly what the trap exists to prevent. Step out onto the folder's first app instead; the same blur still commits the name. The keystroke is stopped at the box because the grid's document-level key handler reads Enter on a focused tile as "launch it", and would otherwise have taken the focus move as its cue to open an app the user never asked for. * fix(gui): stop an open folder clipping its own uninstall badges The folder's grid scrolls, so it clips anything outside its padding box — and reorder mode's uninstall badge deliberately overhangs the top-left corner of every tile. The top row's badges therefore rendered as flat tabs rather than circles, on the one surface where they are a touch user's only way to uninstall an app they have filed away. Give the scroller 9px of top padding for the overhang to sit in and take it straight back off as margin, so the card and everything in it stays exactly where it was. * fix(gui): hold page edge-flips while a folder merge is being offered A tile in the pager's last column sits inside the 60px edge-flip zone, so resting a dragged icon on it — the folder-making gesture — armed the edge dwell alongside the merge dwell, and the page flipped out from under the very folder the user was watching form (the merge target scrolls away mid-offer, and the drop then resolves against its stale resting rect). Worst on phones, where 72px tiles overlap the zone across the whole last column. A live merge offer now holds the edge flip: entering the zone arms no dwell while an offer stands, and an offer that arrives during a running dwell is re-checked at the flip (a resting pointer fires no event that could clear the timer). Carrying the icon off the tile withdraws the offer and the hold with it, so deliberate flips — resting in the bare edge gutter — behave as before. * fix(gui): make Escape cancel a folder rename instead of saving it Escape pressed mid-edit fell through to the folder's close handler, and closing commits whatever the name box holds — so the one key every inline rename uses for "never mind" stored the abandoned half-typed name. Escape in the box now puts the stored name back and steps out to the folder's tiles, exactly the cancel Finder/Explorer taught; with nothing left to cancel (name untouched) it falls through and closes the folder as before, so a second press still exits. * fix(gui): refill the folder well when the merge countdown restarts The merge dwell pins its stillness anchor at first contact with the tile, and a hand decelerating INTO a target routinely covers more than the 7px allowance between that moment and the first tick — so the countdown quietly restarts. The well's fill, tuned to the same 460ms, had already completed by then and just sat there half-open: a drop it seemed to promise a folder for actually reordered. Restart the fill with the countdown, so what the well shows is always the countdown that is actually running. * fix(gui): keep keyboard focus inside an open folder across its edits The folder card is a modal dialog, but three of its flows stranded focus on , where Tab walks the inert grid behind the scrim: - Remove from Folder rebuilds the card's grid after the context menu has dropped focus, so nothing inside the dialog holds it. The rebuild now hands focus back to the same app's tile (or the first) whenever it finds focus on — never stealing from an uninstall modal, which holds it legitimately. - The same eject dissolving the folder re-renders the grid right after _closeGroup's focus hand-back, replacing the very tile it chose. The ejected app's own tile — where the user is looking — takes focus instead. - A click on the card's empty space focused nothing at all. The card now carries tabindex=-1 so such clicks land on it (no visible ring; a focus ring around the whole card would misread plumbing as selection), and the Tab trap wraps from there instead of stepping off through the scrim. * fix(gui): size the folder name box to the name it holds The box sat at the browser's default ~20ch regardless of content: a short name floated in a hover pill far wider than the word, and a 40-character one clipped while the card had room to spare. field-sizing: content hugs the text between a 90px floor and the card's width, iOS-style; engines without the property keep the default box exactly as before. --- src/gui/src/UI/Dashboard/TabApps.js | 1076 +++++++++++++++++++- src/gui/src/UI/Dashboard/appGroups.js | 416 ++++++++ src/gui/src/UI/Dashboard/appGroups.test.js | 332 ++++++ src/gui/src/UI/UIWindow.js | 32 +- src/gui/src/css/dashboard.css | 312 +++++- src/gui/src/i18n/translations/en.js | 7 + 6 files changed, 2104 insertions(+), 71 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/appGroups.js create mode 100644 src/gui/src/UI/Dashboard/appGroups.test.js 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',