diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index a1deb61..c2030b6 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1017,6 +1017,9 @@ button { grid-template-columns: repeat(4, 1fr); gap: 28px 15px; } +.app-grid-slot { + min-height: 73px; +} .app-icon-item { position: relative; min-width: 0; @@ -1172,6 +1175,10 @@ button { backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); } +.app-dock-slot { + min-width: 0; + height: 56px; +} .springboard-edit-done { position: absolute; z-index: 12; diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index 25e39ae..f84453a 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDefaultHomeLayout, + HOME_GRID_PAGE_SIZE, moveHomeApp, parseHomeLayout, removeHomeApp, @@ -17,59 +18,105 @@ const defaults = createDefaultHomeLayout( ) describe('home layout', () => { - it('uses the registry arrangement for devices without a saved layout', () => { - expect(parseHomeLayout(undefined, defaults, [...installed])).toEqual( - defaults, - ) - }) + it('uses fixed grid and dock slots for the registry arrangement', () => { + const layout = parseHomeLayout(undefined, defaults, [...installed]) - it('validates persisted ids and appends newly installed apps', () => { - expect( - parseHomeLayout( - { - dock: ['messages', 'invalid', 'messages'], - grid: ['mail'], - hidden: ['phone'], - }, - defaults, - [...installed], - ), - ).toEqual({ - dock: ['messages'], - grid: ['mail', 'clock', 'notes'], - hidden: ['phone'], - }) - }) - - it('reorders grid apps and swaps a full dock slot back into the grid', () => { - const layout: HomeLayout = { - dock: ['phone', 'messages', 'clock', 'notes'], - grid: ['phone', 'messages', 'mail', 'clock', 'notes'], - hidden: [], - } - - const reordered = moveHomeApp(layout, 'mail', 'grid', 'grid', 0) - expect(reordered.grid).toEqual([ - 'mail', + expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE) + expect(layout.grid.slice(0, 6)).toEqual([ 'phone', 'messages', + 'mail', + 'clock', + 'notes', + null, + ]) + expect(layout.dock).toEqual(['phone', 'messages', 'clock', null]) + expect(layout.version).toBe(2) + }) + + it('migrates compact persisted arrays and appends newly installed apps', () => { + const layout = parseHomeLayout( + { + dock: ['messages', 'invalid', 'messages'], + grid: ['mail'], + hidden: ['phone'], + }, + defaults, + [...installed], + ) + + expect(layout.dock).toEqual(['messages', null, null, null]) + expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null]) + expect(layout.hidden).toEqual(['phone']) + expect(layout.version).toBe(2) + }) + + it('preserves explicit gaps in versioned layouts', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE }, + () => null, + ) + grid[0] = 'phone' + grid[7] = 'mail' + + const layout = parseHomeLayout( + { + dock: ['messages', null, 'clock', null], + grid, + hidden: ['notes'], + version: 2, + }, + defaults, + [...installed], + ) + + expect(layout.grid[0]).toBe('phone') + expect(layout.grid[1]).toBeNull() + expect(layout.grid[7]).toBe('mail') + expect(layout.dock).toEqual(['messages', null, 'clock', null]) + }) + + it('moves to an exact empty slot without compacting other apps', () => { + const moved = moveHomeApp(defaults, 'mail', 'grid', 'grid', 12) + + expect(moved.grid[2]).toBeNull() + expect(moved.grid[12]).toBe('mail') + expect(moved.grid[0]).toBe('phone') + expect(moved.grid[4]).toBe('notes') + }) + + it('swaps occupied slots and avoids duplicate displaced shortcuts', () => { + const reordered = moveHomeApp(defaults, 'mail', 'grid', 'grid', 0) + expect(reordered.grid.slice(0, 5)).toEqual([ + 'mail', + 'messages', + 'phone', 'clock', 'notes', ]) const docked = moveHomeApp(reordered, 'mail', 'grid', 'dock', 2) - expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'notes']) - expect(docked.grid).toEqual(['phone', 'messages', 'clock', 'notes']) + expect(docked.dock).toEqual(['phone', 'messages', 'mail', null]) + expect(docked.grid[0]).toBeNull() + expect(docked.grid.filter((id) => id === 'clock')).toHaveLength(1) }) - it('removes every home shortcut and can restore it from the library', () => { - const removed = removeHomeApp(defaults, 'phone') - expect(removed.grid).not.toContain('phone') - expect(removed.dock).not.toContain('phone') + it('removes shortcuts without closing gaps and restores the first gap', () => { + const layout: HomeLayout = moveHomeApp( + defaults, + 'phone', + 'grid', + 'grid', + 10, + ) + const removed = removeHomeApp(layout, 'phone') + expect(removed.grid[0]).toBeNull() + expect(removed.grid[10]).toBeNull() + expect(removed.dock[0]).toBeNull() expect(removed.hidden).toContain('phone') const restored = restoreHomeApp(removed, 'phone') - expect(restored.grid.at(-1)).toBe('phone') + expect(restored.grid[0]).toBe('phone') expect(restored.hidden).not.toContain('phone') }) }) diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 2f3f99a..b42d6fa 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -1,19 +1,22 @@ import type { LaunchablePhoneAppId } from '@/types/apps' export const HOME_DOCK_CAPACITY = 4 +export const HOME_GRID_PAGE_SIZE = 20 +const MAX_HOME_GRID_PAGES = 5 export type HomeArea = 'dock' | 'grid' +export type HomeSlot = LaunchablePhoneAppId | null export type HomeLayout = { - dock: LaunchablePhoneAppId[] - grid: LaunchablePhoneAppId[] + dock: HomeSlot[] + grid: HomeSlot[] hidden: LaunchablePhoneAppId[] + version: 2 } function readAppIds( value: unknown, availableIds: Set, - maximum = Number.POSITIVE_INFINITY, ): LaunchablePhoneAppId[] { if (!Array.isArray(value)) return [] @@ -26,24 +29,79 @@ function readAppIds( ) { ids.push(valueId as LaunchablePhoneAppId) } - if (ids.length === maximum) break } return ids } +function createSlots(length: number): HomeSlot[] { + return Array.from({ length }, () => null) +} + +function getGridCapacity(itemCount: number): number { + return Math.max( + HOME_GRID_PAGE_SIZE, + Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE, + ) +} + +function readSlots( + value: unknown, + availableIds: Set, + length: number, +): HomeSlot[] { + const slots = createSlots(length) + if (!Array.isArray(value)) return slots + + const usedIds = new Set() + for (let index = 0; index < Math.min(value.length, length); index += 1) { + const valueId = value[index] + if ( + typeof valueId === 'string' && + availableIds.has(valueId as LaunchablePhoneAppId) && + !usedIds.has(valueId as LaunchablePhoneAppId) + ) { + slots[index] = valueId as LaunchablePhoneAppId + usedIds.add(valueId as LaunchablePhoneAppId) + } + } + return slots +} + +function placeInFirstEmptySlot( + slots: HomeSlot[], + appId: LaunchablePhoneAppId, +): void { + const emptyIndex = slots.indexOf(null) + if (emptyIndex !== -1) { + slots[emptyIndex] = appId + return + } + + slots.push(...createSlots(HOME_GRID_PAGE_SIZE)) + slots[slots.length - HOME_GRID_PAGE_SIZE] = appId +} + export function createDefaultHomeLayout( installedIds: LaunchablePhoneAppId[], defaultGridIds: LaunchablePhoneAppId[], defaultDockIds: LaunchablePhoneAppId[], ): HomeLayout { const installed = new Set(installedIds) - return { - dock: defaultDockIds - .filter((id) => installed.has(id)) - .slice(0, HOME_DOCK_CAPACITY), - grid: defaultGridIds.filter((id) => installed.has(id)), - hidden: [], + const gridIds = defaultGridIds.filter((id) => installed.has(id)) + const grid = createSlots(getGridCapacity(gridIds.length)) + for (let index = 0; index < gridIds.length; index += 1) { + grid[index] = gridIds[index] } + + const dock = createSlots(HOME_DOCK_CAPACITY) + for (const [index, id] of defaultDockIds + .filter((id) => installed.has(id)) + .slice(0, HOME_DOCK_CAPACITY) + .entries()) { + dock[index] = id + } + + return { dock, grid, hidden: [], version: 2 } } export function parseHomeLayout( @@ -57,28 +115,48 @@ export function parseHomeLayout( const availableIds = new Set(installedIds) const hidden = readAppIds(source.hidden, availableIds) const hiddenIds = new Set(hidden) - const dock = readAppIds(source.dock, availableIds, HOME_DOCK_CAPACITY).filter( - (id) => !hiddenIds.has(id), + const persistedGridLength = Array.isArray(source.grid) + ? Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) + : 0 + const gridLength = Math.max( + defaults.grid.length, + getGridCapacity(persistedGridLength), ) - const grid = readAppIds(source.grid, availableIds).filter( - (id) => !hiddenIds.has(id), + let grid: HomeSlot[] + let dock: HomeSlot[] + + if (source.version === 2) { + grid = readSlots(source.grid, availableIds, gridLength) + dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY) + } else { + grid = createSlots(gridLength) + for (const id of readAppIds(source.grid, availableIds)) { + placeInFirstEmptySlot(grid, id) + } + dock = createSlots(HOME_DOCK_CAPACITY) + for (const [index, id] of readAppIds(source.dock, availableIds) + .slice(0, HOME_DOCK_CAPACITY) + .entries()) { + dock[index] = id + } + } + + grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null)) + dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null)) + const placedIds = new Set( + [...grid, ...dock, ...hidden].filter( + (id): id is LaunchablePhoneAppId => id !== null, + ), ) - const placedIds = new Set([...dock, ...grid, ...hidden]) for (const id of defaults.grid) { - if (!placedIds.has(id)) { - grid.push(id) - placedIds.add(id) - } - } - for (const id of defaults.dock) { - if (!placedIds.has(id) && dock.length < HOME_DOCK_CAPACITY) { - dock.push(id) + if (id && !placedIds.has(id)) { + placeInFirstEmptySlot(grid, id) placedIds.add(id) } } - return { dock, grid, hidden } + return { dock, grid, hidden, version: 2 } } export function removeHomeApp( @@ -86,11 +164,12 @@ export function removeHomeApp( appId: LaunchablePhoneAppId, ): HomeLayout { return { - dock: layout.dock.filter((id) => id !== appId), - grid: layout.grid.filter((id) => id !== appId), + dock: layout.dock.map((id) => (id === appId ? null : id)), + grid: layout.grid.map((id) => (id === appId ? null : id)), hidden: layout.hidden.includes(appId) ? [...layout.hidden] : [...layout.hidden, appId], + version: 2, } } @@ -99,10 +178,13 @@ export function restoreHomeApp( appId: LaunchablePhoneAppId, ): HomeLayout { if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout + const grid = [...layout.grid] + placeInFirstEmptySlot(grid, appId) return { dock: [...layout.dock], - grid: [...layout.grid, appId], + grid, hidden: layout.hidden.filter((id) => id !== appId), + version: 2, } } @@ -117,30 +199,34 @@ export function moveHomeApp( dock: [...layout.dock], grid: [...layout.grid], hidden: layout.hidden.filter((id) => id !== appId), + version: 2, } const source = next[from] const sourceIndex = source.indexOf(appId) - if (sourceIndex === -1) return layout - - source.splice(sourceIndex, 1) const target = next[to] - const duplicateIndex = target.indexOf(appId) - if (duplicateIndex !== -1) target.splice(duplicateIndex, 1) + if (sourceIndex === -1 || targetIndex < 0 || targetIndex >= target.length) { + return layout + } - const insertionIndex = Math.max(0, Math.min(targetIndex, target.length)) - if (to === 'dock' && target.length >= HOME_DOCK_CAPACITY) { - const displacedIndex = Math.min(insertionIndex, target.length - 1) - const [displacedApp] = target.splice(displacedIndex, 1, appId) - if ( - from === 'grid' && - displacedApp !== undefined && - !source.includes(displacedApp) - ) { - source.splice(Math.min(sourceIndex, source.length), 0, displacedApp) - } + if (from === to) { + const displacedApp = source[targetIndex] + source[targetIndex] = appId + source[sourceIndex] = displacedApp return next } - target.splice(insertionIndex, 0, appId) + const duplicateIndex = target.indexOf(appId) + if (duplicateIndex !== -1) { + target[duplicateIndex] = null + } + + const displacedApp = target[targetIndex] + source[sourceIndex] = + displacedApp && + displacedApp !== appId && + !source.some((id, index) => id === displacedApp && index !== sourceIndex) + ? displacedApp + : null + target[targetIndex] = appId return next } diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index c0e0729..9773b80 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -10,10 +10,9 @@ import { useAppStoreStore } from '@/stores/app-store' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps' import type { LaunchablePhoneAppId } from '@/types/apps' -import type { HomeArea } from '@/utils/homeLayout' +import { HOME_GRID_PAGE_SIZE, type HomeArea } from '@/utils/homeLayout' import { paginateItems } from '@/utils/pages' -const APPS_PER_HOME_PAGE = 20 const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [ 'games', 'productivity', @@ -44,25 +43,23 @@ const installedApps = computed(() => const installedAppsById = computed( () => new Map(installedApps.value.map((app) => [app.id, app])), ) -const gridApps = computed(() => - appStore.homeLayout.grid.flatMap((id) => { - const app = installedAppsById.value.get(id) - return app ? [app] : [] - }), +const gridSlots = computed(() => + appStore.homeLayout.grid.map((id) => + id ? (installedAppsById.value.get(id) ?? null) : null, + ), ) const appPages = computed(() => - paginateItems(gridApps.value, APPS_PER_HOME_PAGE), + paginateItems(gridSlots.value, HOME_GRID_PAGE_SIZE), ) const pageCount = computed(() => appPages.value.length + 2) const libraryPage = computed(() => pageCount.value - 1) const isAppPage = computed( () => phone.currentPage > 0 && phone.currentPage < libraryPage.value, ) -const dockApps = computed(() => - appStore.homeLayout.dock.flatMap((id) => { - const app = installedAppsById.value.get(id) - return app ? [app] : [] - }), +const dockSlots = computed(() => + appStore.homeLayout.dock.map((id) => + id ? (installedAppsById.value.get(id) ?? null) : null, + ), ) const filteredApps = computed(() => { const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang) @@ -240,19 +237,33 @@ watch(isAppPage, (visible) => { :aria-label="phone.t('Home.apps')" >
- + :key=" + app?.id ?? + `grid-empty-${pageIndex * HOME_GRID_PAGE_SIZE + appIndex}` + " + > + + +
@@ -368,20 +379,31 @@ watch(isAppPage, (visible) => { :aria-label="phone.t('Home.dock')" data-home-area="dock" > - + diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 7c6b2d3..081aac1 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +