ENH - rework homescreen editing and widgets

This commit is contained in:
DerEchteAlec
2026-08-17 05:16:28 +02:00
parent 6a8be4d679
commit 7d3a3b7b38
23 changed files with 2218 additions and 1115 deletions
+88 -8
View File
@@ -12,6 +12,7 @@ import {
HOME_GRID_COLUMNS,
HOME_GRID_PAGE_SIZE,
HOME_GRID_ROWS,
HOME_LAYOUT_VERSION,
homeKeyboardTarget,
isHomeFolder,
MAX_HOME_GRID_PAGES,
@@ -19,6 +20,7 @@ import {
moveHomeAppToGridPage,
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -52,7 +54,8 @@ function pageLayout(pageCounts: readonly number[]): HomeLayout {
dock: [generatedApp(999), null, null, null],
grid,
hidden: [],
version: 5,
pageCount: pageCounts.length,
version: HOME_LAYOUT_VERSION,
}
}
@@ -72,7 +75,7 @@ describe('home layout', () => {
null,
])
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
@@ -89,7 +92,7 @@ describe('home layout', () => {
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(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('closes gaps within a versioned page without moving apps between pages', () => {
@@ -127,7 +130,7 @@ describe('home layout', () => {
.slice(0, HOME_GRID_PAGE_SIZE)
.filter((item) => item !== null).length
expect(layout.grid.slice(0, pageItemCount)).not.toContain(null)
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves page membership while expanding version 3 pages', () => {
@@ -147,7 +150,7 @@ describe('home layout', () => {
expect(layout.grid.slice(0, 3)).toEqual(['phone', 'messages', 'notes'])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('mail')
expect(layout.grid[HOME_GRID_PAGE_SIZE + 1]).toBe('clock')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('reads version 4 pages directly before migrating the schema', () => {
@@ -167,7 +170,7 @@ describe('home layout', () => {
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('messages')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves folders and page membership in version 5 layouts', () => {
@@ -194,7 +197,7 @@ describe('home layout', () => {
'notes',
])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('clock')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves independently positioned shortcuts for the same app', () => {
@@ -323,6 +326,61 @@ describe('home layout', () => {
)
})
it('materializes the visible pages before moving a widget forward', () => {
const layout = pageLayout([0, HOME_GRID_PAGE_SIZE, 7])
const pageTwoApps = layout.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter((item) => item !== null)
const pageThreeApps = layout.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter((item) => item !== null)
const reflowed = reflowHomeGridForWidgetChange(
layout,
[HOME_GRID_PAGE_SIZE, 20, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE, 20],
)
expect(reflowed).not.toBeNull()
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toEqual(pageTwoApps.slice(0, 20))
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter(Boolean),
).toEqual([...pageTwoApps.slice(20), ...pageThreeApps])
})
it('moves widget overflow only to later pages', () => {
const layout = pageLayout([0, HOME_GRID_PAGE_SIZE, 20])
const allApps = layout.grid.filter(Boolean).sort()
const reflowed = reflowHomeGridForWidgetChange(
layout,
[HOME_GRID_PAGE_SIZE, 20, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE, 20],
)
expect(reflowed).not.toBeNull()
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toHaveLength(20)
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter(Boolean),
).toHaveLength(20)
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 3, HOME_GRID_PAGE_SIZE * 4)
.filter(Boolean),
).toHaveLength(4)
expect(reflowed?.grid.filter(Boolean).sort()).toEqual(allApps)
})
it('provides bounded keyboard reorder targets without wrapping rows', () => {
expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2)
expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull()
@@ -423,15 +481,37 @@ describe('home layout', () => {
}
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
expect(layout.pageCount).toBe(MAX_HOME_GRID_PAGES)
expect(addHomePage(layout)).toBe(layout)
})
it('restores an explicitly persisted empty trailing page', () => {
const layout = parseHomeLayout(
{
dock: defaults.dock,
grid: defaults.grid.slice(0, 5),
hidden: [],
pageCount: 2,
version: HOME_LAYOUT_VERSION,
},
defaults,
[...installed],
)
expect(layout.pageCount).toBe(2)
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(
layout.grid.slice(HOME_GRID_PAGE_SIZE).every((item) => item === null),
).toBe(true)
})
it('deletes a page and moves its items into remaining empty slots', () => {
let layout = addHomePage(defaults)
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
const deleted = deleteHomePage(layout, 2)
expect(deleted.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(deleted.pageCount).toBe(1)
expect(deleted.grid).toContain('phone')
expect(deleteHomePage(deleted, 1)).toBe(deleted)
})
@@ -462,7 +542,7 @@ describe('home layout', () => {
' Dienstprogramme ',
)
const parsed = parseHomeLayout(renamed, defaults, [...installed])
expect(parsed.version).toBe(5)
expect(parsed.version).toBe(HOME_LAYOUT_VERSION)
expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({
apps: ['notes', 'mail'],
id: 'folder-work-123456',
+92 -9
View File
@@ -8,7 +8,7 @@ export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS
export const HOME_FOLDER_PAGE_SIZE = 9
export const HOME_FOLDER_NAME_MAX_LENGTH = 32
export const MAX_HOME_GRID_PAGES = 5
export const HOME_LAYOUT_VERSION = 5
export const HOME_LAYOUT_VERSION = 6
const LEGACY_HOME_GRID_PAGE_SIZE = 20
export type HomeArea = 'dock' | 'grid'
@@ -27,6 +27,7 @@ export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
pageCount: number
version: typeof HOME_LAYOUT_VERSION
}
@@ -90,10 +91,19 @@ function cloneLayout(layout: HomeLayout): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: layout.grid.map(cloneItem),
hidden: [...layout.hidden],
pageCount: layout.pageCount,
version: HOME_LAYOUT_VERSION,
}
}
function clampPageCount(value: number): number {
return Math.max(1, Math.min(MAX_HOME_GRID_PAGES, Math.trunc(value)))
}
function inferredPageCount(slots: readonly HomeSlot[]): number {
return clampPageCount(Math.ceil(slots.length / HOME_GRID_PAGE_SIZE))
}
function normalizeFolder(folder: HomeFolder): HomeSlot {
if (folder.apps.length === 0) return null
if (folder.apps.length === 1) return folder.apps[0]
@@ -354,6 +364,43 @@ function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] {
return slots
}
export function reflowHomeGridForWidgetChange(
layout: HomeLayout,
previousCapacities: HomeGridPageCapacities,
nextCapacities: HomeGridPageCapacities,
): HomeLayout | null {
const currentPages = distributeGridPages(
layout.grid,
previousCapacities,
layout.pageCount,
)
if (!currentPages) return null
const materializedGrid = serializeGridPages(currentPages)
const nextPages = distributeGridPages(
materializedGrid,
nextCapacities,
Math.max(layout.pageCount, currentPages.length),
)
if (!nextPages) return null
const nextGrid = serializeGridPages(nextPages)
const nextPageCount = clampPageCount(
Math.max(layout.pageCount, nextPages.length),
)
if (
nextPageCount === layout.pageCount &&
JSON.stringify(nextGrid) === JSON.stringify(layout.grid)
) {
return layout
}
const next = cloneLayout(layout)
next.grid = nextGrid
next.pageCount = nextPageCount
return next
}
export function moveHomeAppToGridPage(
layout: HomeLayout,
from: HomeArea,
@@ -416,6 +463,9 @@ export function moveHomeAppToGridPage(
const next = cloneLayout(layout)
next.grid = serializeGridPages(pages)
next.pageCount = clampPageCount(
Math.max(layout.pageCount, targetPage, pages.length),
)
if (from === 'dock') next.dock[sourceIndex] = null
if (
JSON.stringify(next.grid) === JSON.stringify(layout.grid) &&
@@ -446,7 +496,13 @@ export function createDefaultHomeLayout(
dock[index] = id
}
return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION }
return {
dock,
grid,
hidden: [],
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
export function parseHomeLayout(
@@ -461,13 +517,19 @@ export function parseHomeLayout(
const availableIds = new Set(installedIds)
if (
preservePersistedIds &&
(source.version === 3 || source.version === 4 || source.version === 5)
(source.version === 3 ||
source.version === 4 ||
source.version === 5 ||
source.version === 6)
) {
for (const collection of [source.dock, source.grid, source.hidden]) {
if (!Array.isArray(collection)) continue
for (const item of collection) {
if (isPersistableAppId(item)) availableIds.add(item)
if (source.version === 5 && isHomeFolder(item)) {
if (
(source.version === 5 || source.version === 6) &&
isHomeFolder(item)
) {
for (const appId of item.apps) {
if (isPersistableAppId(appId)) availableIds.add(appId)
}
@@ -488,8 +550,15 @@ export function parseHomeLayout(
) * HOME_GRID_PAGE_SIZE
: Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
: 0
const persistedPageCount =
source.version === 6 &&
typeof source.pageCount === 'number' &&
Number.isInteger(source.pageCount)
? clampPageCount(source.pageCount)
: clampPageCount(Math.ceil(persistedGridLength / HOME_GRID_PAGE_SIZE))
const gridLength = Math.max(
defaults.grid.length,
persistedPageCount * HOME_GRID_PAGE_SIZE,
getGridCapacity(persistedGridLength),
)
let grid: HomeSlot[]
@@ -507,9 +576,13 @@ export function parseHomeLayout(
new Set(),
false,
)
} else if (source.version === 4 || source.version === 5) {
} else if (
source.version === 4 ||
source.version === 5 ||
source.version === 6
) {
const folderIds = new Set<string>()
const allowFolders = source.version === 5
const allowFolders = source.version === 5 || source.version === 6
grid = readSlots(
source.grid,
availableIds,
@@ -567,6 +640,7 @@ export function parseHomeLayout(
dock: dock.map(cloneItem),
grid: compactGridPages(grid),
hidden,
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
@@ -589,6 +663,7 @@ export function removeHomeApp(
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
pageCount: layout.pageCount,
version: HOME_LAYOUT_VERSION,
}
}
@@ -613,25 +688,32 @@ export function restoreHomeApp(
dock: layout.dock.map(cloneItem),
grid: compactGridPages(grid),
hidden: layout.hidden.filter((id) => id !== appId),
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
export function addHomePage(layout: HomeLayout): HomeLayout {
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
if (layout.pageCount >= MAX_HOME_GRID_PAGES) {
return layout
}
const pageCount = layout.pageCount + 1
const requiredGridLength = pageCount * HOME_GRID_PAGE_SIZE
return {
dock: layout.dock.map(cloneItem),
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
grid: [
...layout.grid.map(cloneItem),
...createSlots(Math.max(0, requiredGridLength - layout.grid.length)),
],
hidden: [...layout.hidden],
pageCount,
version: HOME_LAYOUT_VERSION,
}
}
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
const pageCount = layout.pageCount
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
@@ -648,6 +730,7 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: compactGridPages(grid),
hidden: [...layout.hidden],
pageCount: pageCount - 1,
version: HOME_LAYOUT_VERSION,
}
}
+75 -6
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
maximumRenderedWidgetPage,
nearestSpringboardRectIndex,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardEdgeDirection,
@@ -18,6 +19,17 @@ describe('springboard widget drag', () => {
expect(springboardEdgeDirection(284, 100, 468)).toBe(0)
})
it('keeps both phone edges reachable at normal and reduced FiveM scales', () => {
expect(springboardEdgeDirection(125, 100, 468)).toBe(-1)
expect(springboardEdgeDirection(150, 100, 468)).toBe(0)
expect(springboardEdgeDirection(420, 100, 468)).toBe(0)
expect(springboardEdgeDirection(443, 100, 468)).toBe(1)
expect(springboardEdgeDirection(117, 100, 247)).toBe(-1)
expect(springboardEdgeDirection(200, 100, 247)).toBe(0)
expect(springboardEdgeDirection(230, 100, 247)).toBe(1)
expect(springboardEdgeDirection(100, 100, 100)).toBe(0)
})
it('resolves reachable page destinations without crossing page bounds', () => {
expect(resolveSpringboardEdgeTurn(455, 100, 468, 1, 0, 3)).toEqual({
destination: 2,
@@ -31,6 +43,34 @@ describe('springboard widget drag', () => {
expect(resolveSpringboardEdgeTurn(455, 100, 468, 3, 0, 3)).toBeNull()
})
it('resolves consecutive edge turns while an app remains at the same edge', () => {
const firstTurn = resolveSpringboardHomeEdgeTurn(
113,
100,
468,
4,
4,
5,
false,
)
expect(firstTurn).toEqual({
destination: 3,
direction: -1,
previewsPage: false,
})
expect(
resolveSpringboardHomeEdgeTurn(
113,
100,
468,
firstTurn?.destination ?? 4,
4,
5,
false,
),
).toEqual({ destination: 2, direction: -1, previewsPage: false })
})
it('previews one new trailing page without crossing the home-page limit', () => {
expect(
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 2, 5, false),
@@ -60,15 +100,44 @@ describe('springboard widget drag', () => {
expect(springboardPageDragCompensation(2, 2, 368)).toBe(0)
})
it('selects drop slots directly in viewport order', () => {
const slots = [
{ height: 80, left: 100, top: 150, width: 70 },
{ height: 80, left: 180, top: 150, width: 70 },
{ height: 80, left: 260, top: 150, width: 70 },
]
expect(nearestSpringboardRectIndex(115, 190, slots)).toBe(0)
expect(nearestSpringboardRectIndex(300, 190, slots)).toBe(2)
expect(nearestSpringboardRectIndex(220, 190, slots)).toBe(1)
expect(nearestSpringboardRectIndex(220, 190, [])).toBeNull()
})
it('converts viewport coordinates into local phone coordinates under zoom', () => {
expect(
springboardViewportToLocal(169, 238, 100, 100, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
const point = springboardViewportToLocal(
169,
238,
100,
100,
253.92,
582.36,
368,
844,
)
expect(point.x).toBeCloseTo(100)
expect(point.y).toBeCloseTo(200)
expect(
springboardViewportToLocal(200, 250, 100, 50, 0, 0, 368, 844),
).toEqual({ x: 100, y: 200 })
expect(
springboardViewportDeltaToLocal(69, 138, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
const delta = springboardViewportDeltaToLocal(
69,
138,
253.92,
582.36,
368,
844,
)
expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200)
})
})
+57 -1
View File
@@ -16,6 +16,40 @@ export type SpringboardLocalPoint = {
y: number
}
export type SpringboardViewportRect = {
height: number
left: number
top: number
width: number
}
export type SpringboardDragMetrics = {
layoutHeight: number
layoutWidth: number
viewportHeight: number
viewportLeft: number
viewportTop: number
viewportWidth: number
}
export function readSpringboardDragMetrics(
element: Element | null,
): SpringboardDragMetrics | null {
const surface = element?.closest<HTMLElement>(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds = surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
viewportHeight: bounds.height,
viewportLeft: bounds.left,
viewportTop: bounds.top,
viewportWidth: bounds.width,
}
}
export function springboardViewportToLocal(
clientX: number,
clientY: number,
@@ -55,6 +89,27 @@ export function springboardPageDragCompensation(
return (currentPage - startPage) * Math.max(0, pageWidth)
}
export function nearestSpringboardRectIndex(
clientX: number,
clientY: number,
rects: readonly SpringboardViewportRect[],
): number | null {
if (!rects.length) return null
let nearestIndex = 0
let nearestDistance = Number.POSITIVE_INFINITY
for (const [index, rect] of rects.entries()) {
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const distance = Math.hypot(clientX - centerX, clientY - centerY)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
}
return nearestIndex
}
export function springboardSwipeIntent(
deltaX: number,
deltaY: number,
@@ -70,7 +125,8 @@ export function springboardEdgeDirection(
right: number,
): PageTurnDirection {
const width = Math.max(0, right - left)
const edgeSize = Math.min(42, width * 0.12)
if (width === 0) return 0
const edgeSize = Math.min(48, Math.max(28, width * 0.12))
if (clientX <= left + edgeSize) return -1
if (clientX >= right - edgeSize) return 1
return 0