diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index 3cf02ca..e5bcdd4 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -11,6 +11,8 @@ import { useAppContext } from '@/hooks/useAppContext'; import { useTheme } from '@/hooks/useTheme'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useWindowManager } from '@/os/useWindowManager'; +import { desktopApps } from '@/os/registry'; +import { useIconLayout } from '@/os/useIconLayout'; import { useToast } from '@/hooks/useToast'; import { npubOf } from '@/lib/nostrUtils'; import { cn } from '@/lib/utils'; @@ -42,6 +44,8 @@ export default function SettingsApp({ setTitle }: AppProps) { + + @@ -49,6 +53,32 @@ export default function SettingsApp({ setTitle }: AppProps) { ); } +function IconLayoutSection() { + const { toast } = useToast(); + const appIds = desktopApps().map((app) => app.id); + // Reset uses a deterministic, measurement-free registry order. The desktop + // clamps it to its actual surface on render; mobile reflows into its columns. + const { reset } = useIconLayout(appIds, { columns: 8, rows: 16 }); + + const resetAndToast = (profile: 'desktop' | 'mobile' | 'both') => { + reset(profile); + toast({ title: profile === 'both' ? 'All icon layouts reset' : `${profile === 'desktop' ? 'Desktop' : 'Mobile'} icon layout reset` }); + }; + + return ( +
+
+ + + +
+
+ ); +} + function Section({ title, description, diff --git a/src/components/os/Desktop.tsx b/src/components/os/Desktop.tsx index 9d77882..50e8645 100644 --- a/src/components/os/Desktop.tsx +++ b/src/components/os/Desktop.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ContextMenu, ContextMenuContent, @@ -11,6 +11,20 @@ import { WindowLayer } from './WindowLayer'; import { useWindowManager } from '@/os/useWindowManager'; import { desktopApps } from '@/os/registry'; import { MENUBAR_HEIGHT } from '@/os/layout'; +import { swapDesktopSlots, type DesktopSlot, type GridGeometry } from '@/os/iconLayout'; +import { useIconLayout } from '@/os/useIconLayout'; + +const CELL_WIDTH = 96; +const CELL_HEIGHT = 92; +const SURFACE_PADDING = 12; +const DRAG_THRESHOLD = 6; + +function geometryFor(width: number, height: number): GridGeometry { + return { + columns: Math.max(1, Math.floor((width - SURFACE_PADDING * 2) / CELL_WIDTH)), + rows: Math.max(1, Math.floor((height - SURFACE_PADDING * 2) / CELL_HEIGHT)), + }; +} /** * The desktop surface: dot-grid wallpaper, the app icons, and the layer the @@ -20,9 +34,102 @@ import { MENUBAR_HEIGHT } from '@/os/layout'; export function Desktop() { const { openApp, windows, minimizeAll, closeAll } = useWindowManager(); const [selected, setSelected] = useState(null); + const [surfaceSize, setSurfaceSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT })); + const [dragging, setDragging] = useState(null); + const [candidate, setCandidate] = useState<{ col: number; row: number } | null>(null); + const [picked, setPicked] = useState(null); + const [pickedLayout, setPickedLayout] = useState(null); + const [announcement, setAnnouncement] = useState(''); + const pointerStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null); const apps = desktopApps(); + const geometry = useMemo(() => geometryFor(surfaceSize.width, surfaceSize.height), [surfaceSize]); + const { layout, setDesktop, reset } = useIconLayout(apps.map((app) => app.id), geometry); + const slots = layout.desktop; + + useEffect(() => { + const onResize = () => setSurfaceSize({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT }); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const cellAt = useCallback((clientX: number, clientY: number) => ({ + col: Math.max(0, Math.min(geometry.columns - 1, Math.round((clientX - SURFACE_PADDING - 40) / CELL_WIDTH))), + row: Math.max(0, Math.min(geometry.rows - 1, Math.round((clientY - MENUBAR_HEIGHT - SURFACE_PADDING - 38) / CELL_HEIGHT))), + }), [geometry]); + + const move = useCallback((id: string, target: { col: number; row: number }) => { + setDesktop((current) => swapDesktopSlots(current, id, target)); + const occupied = slots.find((slot) => slot.col === target.col && slot.row === target.row && slot.id !== id); + setAnnouncement(occupied ? `${id} swapped positions with ${occupied.id}.` : `${id} moved to column ${target.col + 1}, row ${target.row + 1}.`); + }, [setDesktop, slots]); + + const onPointerMove = useCallback((event: PointerEvent) => { + const active = pointerStart.current; + if (!active) return; + if (!active.moved && Math.hypot(event.clientX - active.x, event.clientY - active.y) < DRAG_THRESHOLD) return; + active.moved = true; + setDragging(active.id); + setCandidate(cellAt(event.clientX, event.clientY)); + }, [cellAt]); + const finishPointer = useCallback((event: PointerEvent) => { + const active = pointerStart.current; + if (active?.moved) { + const target = cellAt(event.clientX, event.clientY); + move(active.id, target); + setSelected(active.id); + } + pointerStart.current = null; + setDragging(null); + setCandidate(null); + }, [cellAt, move]); + + useEffect(() => { + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', finishPointer); + window.addEventListener('pointercancel', finishPointer); + return () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', finishPointer); + window.removeEventListener('pointercancel', finishPointer); + }; + }, [finishPointer, onPointerMove]); + + const iconKeyDown = (id: string, event: React.KeyboardEvent) => { + const slot = slots.find((item) => item.id === id); + if (!slot) return; + if (event.key === 'Escape' && picked) { + event.preventDefault(); + if (pickedLayout) setDesktop(() => pickedLayout); + setPicked(null); + setPickedLayout(null); + setAnnouncement('Move cancelled and original position restored.'); + return; + } + if (event.key === ' ' || (event.key === 'Enter' && picked === id)) { + event.preventDefault(); + if (picked === id) { + setPicked(null); + setPickedLayout(null); + setAnnouncement(`${id} dropped at column ${slot.col + 1}, row ${slot.row + 1}.`); + } else { + setPicked(id); + setPickedLayout(slots); + setAnnouncement(`${id} picked up. Use arrow keys to move, Enter to drop, Escape to cancel.`); + } + return; + } + const offsets: Record = { + ArrowLeft: { col: -1, row: 0 }, ArrowRight: { col: 1, row: 0 }, ArrowUp: { col: 0, row: -1 }, ArrowDown: { col: 0, row: 1 }, + }; + const offset = offsets[event.key]; + if (!offset || !picked) return; + event.preventDefault(); + const target = { col: Math.max(0, Math.min(geometry.columns - 1, slot.col + offset.col)), row: Math.max(0, Math.min(geometry.rows - 1, slot.row + offset.row)) }; + move(id, target); + }; return ( + <>
-
- {apps.map((app) => ( - setSelected(app.id)} - onOpen={() => openApp(app.id)} - /> - ))} +
+ {apps.map((app) => { + const slot = slots.find((item) => item.id === app.id); + if (!slot) return null; + const isCandidate = dragging === app.id && candidate; + const displaySlot = isCandidate ? candidate : slot; + return ( + { + if (event.button !== 0) return; + pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false }; + setSelected(app.id); + }} + onKeyDown={(event) => iconKeyDown(app.id, event)} + onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }} + onOpen={() => openApp(app.id)} + /> + ); + })}
@@ -54,6 +174,9 @@ export function Desktop() { openApp('feed')}>Open Feed openApp('settings')}>Open Settings + { reset('desktop'); setAnnouncement('Desktop icon layout reset.'); }}> + Reset desktop icon layout + Minimize all windows @@ -62,6 +185,8 @@ export function Desktop() { Close all windows - + + {announcement} + ); } diff --git a/src/components/os/DesktopIcon.tsx b/src/components/os/DesktopIcon.tsx index 25b7eed..1d878c0 100644 --- a/src/components/os/DesktopIcon.tsx +++ b/src/components/os/DesktopIcon.tsx @@ -6,17 +6,40 @@ interface DesktopIconProps { selected: boolean; onSelect: () => void; onOpen: () => void; + onPointerDown?: (event: React.PointerEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + tabIndex?: number; + dragging?: boolean; + pickedUp?: boolean; + style?: React.CSSProperties; } -export function DesktopIcon({ app, selected, onSelect, onOpen }: DesktopIconProps) { +export function DesktopIcon({ + app, + selected, + onSelect, + onOpen, + onPointerDown, + onKeyDown, + tabIndex, + dragging, + pickedUp, + style, +}: DesktopIconProps) { const Icon = app.icon; return ( ))}
+ {announcement} ); } 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 new file mode 100644 index 0000000..51ff0c7 --- /dev/null +++ b/src/os/iconLayout.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; + +const STORAGE_KEY = 'nostr:icon-layout'; +const VERSION = 1; + +export interface DesktopSlot { + id: string; + col: number; + row: number; +} + +export interface IconLayout { + desktop: DesktopSlot[]; + mobile: string[]; +} + +export interface GridGeometry { + columns: number; + rows: number; +} + +const SlotSchema = z.object({ + id: z.string().min(1), + col: z.number().int().min(0), + row: z.number().int().min(0), +}); + +const LayoutSchema = z.object({ + version: z.literal(VERSION), + desktop: z.array(SlotSchema).max(200), + mobile: z.array(z.string().min(1)).max(200), +}); + +function firstFree(occupied: Set, geometry: GridGeometry): Omit { + for (let row = 0; row < geometry.rows; row += 1) { + for (let col = 0; col < geometry.columns; col += 1) { + if (!occupied.has(`${col}:${row}`)) return { col, row }; + } + } + // A very small viewport can temporarily have fewer cells than apps. Keep + // the remaining icons in the last stable row so they stay on-screen. + return { col: 0, row: Math.max(0, geometry.rows - 1) }; +} + +export function defaultDesktopLayout(ids: string[], geometry: GridGeometry): DesktopSlot[] { + const occupied = new Set(); + return ids.map((id) => { + const slot = firstFree(occupied, geometry); + occupied.add(`${slot.col}:${slot.row}`); + return { id, ...slot }; + }); +} + +/** Drops unknown IDs, clamps coordinates, resolves collisions, then adds new apps. */ +export function reconcileDesktopLayout( + saved: DesktopSlot[], + ids: string[], + geometry: GridGeometry, +): DesktopSlot[] { + const eligible = new Set(ids); + const seen = new Set(); + const occupied = new Set(); + const reconciled: DesktopSlot[] = []; + + for (const slot of saved) { + if (!eligible.has(slot.id) || seen.has(slot.id)) continue; + seen.add(slot.id); + const col = Math.min(slot.col, Math.max(0, geometry.columns - 1)); + const row = Math.min(slot.row, Math.max(0, geometry.rows - 1)); + const key = `${col}:${row}`; + const position = occupied.has(key) ? firstFree(occupied, geometry) : { col, row }; + occupied.add(`${position.col}:${position.row}`); + reconciled.push({ id: slot.id, ...position }); + } + + for (const id of ids) { + if (seen.has(id)) continue; + const position = firstFree(occupied, geometry); + occupied.add(`${position.col}:${position.row}`); + reconciled.push({ id, ...position }); + } + return reconciled; +} + +export function reconcileMobileLayout(saved: string[], ids: string[]): string[] { + const eligible = new Set(ids); + const seen = new Set(); + const ordered = saved.filter((id) => eligible.has(id) && !seen.has(id) && (seen.add(id), true)); + return [...ordered, ...ids.filter((id) => !seen.has(id))]; +} + +export function loadIconLayout(ids: string[], geometry: GridGeometry): IconLayout { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] }; + const parsed = LayoutSchema.parse(JSON.parse(raw)); + return { + desktop: reconcileDesktopLayout(parsed.desktop, ids, geometry), + mobile: reconcileMobileLayout(parsed.mobile, ids), + }; + } catch { + return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] }; + } +} + +export function saveIconLayout(layout: IconLayout): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: VERSION, ...layout })); + } catch { + // Storage can be unavailable in private browsing; the in-memory layout remains usable. + } +} + +export function resetIconLayout(profile: 'desktop' | 'mobile' | 'both'): void { + try { + if (profile === 'both') localStorage.removeItem(STORAGE_KEY); + } catch { + // The caller still resets its in-memory state. + } +} + +export function swapDesktopSlots(slots: DesktopSlot[], id: string, target: Omit): DesktopSlot[] { + const source = slots.find((slot) => slot.id === id); + if (!source) return slots; + const displaced = slots.find((slot) => slot.col === target.col && slot.row === target.row && slot.id !== id); + return slots.map((slot) => { + if (slot.id === id) return { ...slot, ...target }; + if (slot.id === displaced?.id) return { ...slot, col: source.col, row: source.row }; + return slot; + }); +} + +export const iconLayoutStorageKey = STORAGE_KEY; diff --git a/src/os/useIconLayout.ts b/src/os/useIconLayout.ts new file mode 100644 index 0000000..68a65e4 --- /dev/null +++ b/src/os/useIconLayout.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + defaultDesktopLayout, + iconLayoutStorageKey, + loadIconLayout, + reconcileDesktopLayout, + reconcileMobileLayout, + resetIconLayout, + saveIconLayout, + type DesktopSlot, + type GridGeometry, + type IconLayout, +} from './iconLayout'; + +const SAVE_DELAY = 250; + +export function useIconLayout(ids: string[], geometry: GridGeometry) { + const idsKey = ids.join('|'); + const [layout, setLayout] = useState(() => loadIconLayout(ids, geometry)); + const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]); + const stableGeometry = useMemo( + () => ({ columns: geometry.columns, rows: geometry.rows }), + [geometry.columns, geometry.rows], + ); + const normalized = useMemo(() => ({ + desktop: reconcileDesktopLayout(layout.desktop, stableIds, stableGeometry), + mobile: reconcileMobileLayout(layout.mobile, stableIds), + }), [layout, stableGeometry, stableIds]); + + const dirty = useRef(false); + const saveTimer = useRef | undefined>(undefined); + useEffect(() => { + if (!dirty.current) return; + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + dirty.current = false; + saveIconLayout(normalized); + }, SAVE_DELAY); + return () => clearTimeout(saveTimer.current); + }, [normalized]); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== iconLayoutStorageKey) return; + setLayout(loadIconLayout(stableIds, stableGeometry)); + }; + window.addEventListener('storage', onStorage); + const onReset = () => setLayout(loadIconLayout(stableIds, stableGeometry)); + window.addEventListener('icon-layout-reset', onReset); + return () => { + window.removeEventListener('storage', onStorage); + window.removeEventListener('icon-layout-reset', onReset); + }; + }, [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') => { + resetIconLayout(profile); + const next = { + 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')); + }, [normalized.desktop, normalized.mobile, stableGeometry, stableIds]); + + return useMemo(() => ({ layout: normalized, setDesktop, setMobile, reset }), [normalized, reset, setDesktop, setMobile]); +}