diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx index 0c883d1..50e8645 100644 --- a/src/components/os/Desktop.tsx +++ b/src/components/os/Desktop.tsx @@ -152,7 +152,7 @@ export function Desktop() { selected={selected === app.id} dragging={dragging === app.id} pickedUp={picked === app.id} - tabIndex={selected === null || selected === app.id ? 0 : -1} + tabIndex={0} style={{ position: 'absolute', left: SURFACE_PADDING + displaySlot.col * CELL_WIDTH, top: SURFACE_PADDING + displaySlot.row * CELL_HEIGHT, zIndex: dragging === app.id ? 2 : 1 }} onPointerDown={(event) => { if (event.button !== 0) return; diff --git a/src/components/os/MobileAppShell.tsx b/src/components/os/MobileAppShell.tsx index b41a265..27811b1 100644 --- a/src/components/os/MobileAppShell.tsx +++ b/src/components/os/MobileAppShell.tsx @@ -164,6 +164,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) { const [announcement, setAnnouncement] = useState(''); const dragStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null); const suppressClick = useRef(false); + const targetRef = useRef(null); const { layout, setMobile } = useIconLayout(apps.map((app) => app.id), { columns, rows: Math.max(8, Math.ceil(apps.length / columns) + 4) }); const byId = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]); const orderedApps = layout.mobile.map((id) => byId.get(id)).filter((app): app is NonNullable => Boolean(app)); @@ -194,16 +195,20 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) { active.moved = true; setDragging(active.id); const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-home-icon-id]'); - setTarget(hit?.dataset.homeIconId ?? null); + const nextTarget = hit?.dataset.homeIconId ?? null; + targetRef.current = nextTarget; + setTarget(nextTarget); }; const onEnd = () => { const active = dragStart.current; if (active?.moved) { suppressClick.current = true; + const target = targetRef.current; reorder(active.id, target && target !== active.id ? target : null); } dragStart.current = null; setDragging(null); + targetRef.current = null; setTarget(null); }; window.addEventListener('pointermove', onMove); @@ -214,7 +219,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) { window.removeEventListener('pointerup', onEnd); window.removeEventListener('pointercancel', onEnd); }; - }, [reorder, target]); + }, [reorder]); const onKeyDown = (id: string, event: React.KeyboardEvent) => { const index = layout.mobile.indexOf(id); diff --git a/src/os/iconLayout.test.ts b/src/os/iconLayout.test.ts new file mode 100644 index 0000000..99e1198 --- /dev/null +++ b/src/os/iconLayout.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { defaultDesktopLayout, reconcileDesktopLayout, reconcileMobileLayout, swapDesktopSlots } from './iconLayout'; + +const geometry = { columns: 4, rows: 4 }; + +describe('defaultDesktopLayout', () => { + it('places apps in row-major order starting from the first cell', () => { + const slots = defaultDesktopLayout(['a', 'b', 'c'], geometry); + expect(slots).toEqual([ + { id: 'a', col: 0, row: 0 }, + { id: 'b', col: 1, row: 0 }, + { id: 'c', col: 2, row: 0 }, + ]); + }); + + it('keeps overflow icons on the last row when there are more apps than cells', () => { + const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`); + const slots = defaultDesktopLayout(ids, geometry); + for (const slot of slots) { + expect(slot.row).toBeLessThan(geometry.rows); + } + }); +}); + +describe('reconcileDesktopLayout', () => { + it('drops unknown ids and keeps known ones in place', () => { + const saved = [ + { id: 'a', col: 0, row: 0 }, + { id: 'stale', col: 1, row: 0 }, + ]; + const reconciled = reconcileDesktopLayout(saved, ['a'], geometry); + expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]); + }); + + it('clamps out-of-bounds coordinates to the grid', () => { + const saved = [{ id: 'a', col: 99, row: 99 }]; + const reconciled = reconcileDesktopLayout(saved, ['a'], geometry); + expect(reconciled[0].col).toBeLessThan(geometry.columns); + expect(reconciled[0].row).toBeLessThan(geometry.rows); + }); + + it('resolves collisions by moving the later slot to the next free cell', () => { + const saved = [ + { id: 'a', col: 0, row: 0 }, + { id: 'b', col: 0, row: 0 }, + ]; + const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry); + const a = reconciled.find((slot) => slot.id === 'a'); + const b = reconciled.find((slot) => slot.id === 'b'); + expect(a).toEqual({ id: 'a', col: 0, row: 0 }); + expect(b).not.toEqual({ id: 'b', col: 0, row: 0 }); + }); + + it('appends new apps to the first free cell after known ones', () => { + const saved = [{ id: 'a', col: 0, row: 0 }]; + const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry); + expect(reconciled).toEqual([ + { id: 'a', col: 0, row: 0 }, + { id: 'b', col: 1, row: 0 }, + ]); + }); + + it('drops duplicate saved entries for the same id', () => { + const saved = [ + { id: 'a', col: 0, row: 0 }, + { id: 'a', col: 1, row: 0 }, + ]; + const reconciled = reconcileDesktopLayout(saved, ['a'], geometry); + expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]); + }); + + it('keeps overflow icons within the grid when there are more apps than cells', () => { + const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`); + const reconciled = reconcileDesktopLayout([], ids, geometry); + for (const slot of reconciled) { + expect(slot.row).toBeLessThan(geometry.rows); + expect(slot.col).toBeLessThan(geometry.columns); + } + }); +}); + +describe('reconcileMobileLayout', () => { + it('keeps known ids in their saved order and drops unknown ones', () => { + const ordered = reconcileMobileLayout(['b', 'stale', 'a'], ['a', 'b']); + expect(ordered).toEqual(['b', 'a']); + }); + + it('appends new apps that were not in the saved order', () => { + const ordered = reconcileMobileLayout(['a'], ['a', 'b']); + expect(ordered).toEqual(['a', 'b']); + }); + + it('drops duplicate saved entries for the same id', () => { + const ordered = reconcileMobileLayout(['a', 'a', 'b'], ['a', 'b']); + expect(ordered).toEqual(['a', 'b']); + }); +}); + +describe('swapDesktopSlots', () => { + it('swaps the moved icon with the icon occupying the target cell', () => { + const slots = [ + { id: 'a', col: 0, row: 0 }, + { id: 'b', col: 1, row: 0 }, + ]; + const next = swapDesktopSlots(slots, 'a', { col: 1, row: 0 }); + expect(next).toEqual([ + { id: 'a', col: 1, row: 0 }, + { id: 'b', col: 0, row: 0 }, + ]); + }); + + it('moves the icon to an empty cell without disturbing others', () => { + const slots = [ + { id: 'a', col: 0, row: 0 }, + { id: 'b', col: 1, row: 0 }, + ]; + const next = swapDesktopSlots(slots, 'a', { col: 2, row: 0 }); + expect(next).toEqual([ + { id: 'a', col: 2, row: 0 }, + { id: 'b', col: 1, row: 0 }, + ]); + }); + + it('returns the original slots unchanged when the id is not found', () => { + const slots = [{ id: 'a', col: 0, row: 0 }]; + const next = swapDesktopSlots(slots, 'missing', { col: 1, row: 0 }); + expect(next).toBe(slots); + }); +}); diff --git a/src/os/iconLayout.ts b/src/os/iconLayout.ts index 30ef6f3..51ff0c7 100644 --- a/src/os/iconLayout.ts +++ b/src/os/iconLayout.ts @@ -38,8 +38,8 @@ function firstFree(occupied: Set, geometry: GridGeometry): Omit | undefined>(undefined); useEffect(() => { + if (!dirty.current) return; clearTimeout(saveTimer.current); - saveTimer.current = setTimeout(() => saveIconLayout(normalized), SAVE_DELAY); + saveTimer.current = setTimeout(() => { + dirty.current = false; + saveIconLayout(normalized); + }, SAVE_DELAY); return () => clearTimeout(saveTimer.current); }, [normalized]); @@ -49,12 +54,14 @@ export function useIconLayout(ids: string[], geometry: GridGeometry) { }, [stableGeometry, stableIds]); const setDesktop = useCallback((updater: (slots: DesktopSlot[]) => DesktopSlot[]) => { + dirty.current = true; setLayout((current) => ({ ...current, desktop: updater(reconcileDesktopLayout(current.desktop, stableIds, stableGeometry)), })); }, [stableGeometry, stableIds]); const setMobile = useCallback((updater: (order: string[]) => string[]) => { + dirty.current = true; setLayout((current) => ({ ...current, mobile: updater(reconcileMobileLayout(current.mobile, stableIds)) })); }, [stableIds]); const reset = useCallback((profile: 'desktop' | 'mobile' | 'both') => { @@ -63,6 +70,8 @@ export function useIconLayout(ids: string[], geometry: GridGeometry) { desktop: profile === 'mobile' ? normalized.desktop : defaultDesktopLayout(stableIds, stableGeometry), mobile: profile === 'desktop' ? normalized.mobile : [...stableIds], }; + dirty.current = false; + clearTimeout(saveTimer.current); saveIconLayout(next); setLayout(next); window.dispatchEvent(new Event('icon-layout-reset'));