feat: customizable desktop and mobile icon layouts (#40)

* feat: add customizable icon layouts

* Address PR #40 review feedback on icon layout and drag interactions

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
mroxso
2026-09-06 22:39:22 +02:00
committed by GitHub
parent 4c20938098
commit b94753ec68
7 changed files with 674 additions and 23 deletions

View File

@@ -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) {
<Separator />
<MediaSection />
<Separator />
<IconLayoutSection />
<Separator />
<SessionSection />
</div>
</AppBody>
@@ -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 (
<Section
title="Home screen layout"
description="Arrange icons by dragging them. On a keyboard, press Space to pick up an icon, use the arrow keys to move it, then press Enter to drop."
>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => resetAndToast('desktop')}>Reset desktop</Button>
<Button variant="outline" onClick={() => resetAndToast('mobile')}>Reset mobile</Button>
<Button variant="outline" onClick={() => resetAndToast('both')}>Reset both</Button>
</div>
</Section>
);
}
function Section({
title,
description,

View File

@@ -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<string | null>(null);
const [surfaceSize, setSurfaceSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT }));
const [dragging, setDragging] = useState<string | null>(null);
const [candidate, setCandidate] = useState<{ col: number; row: number } | null>(null);
const [picked, setPicked] = useState<string | null>(null);
const [pickedLayout, setPickedLayout] = useState<DesktopSlot[] | null>(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<HTMLButtonElement>) => {
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<string, { col: number; row: number }> = {
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 (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<main
@@ -32,19 +139,32 @@ export function Desktop() {
if (event.target === event.currentTarget) setSelected(null);
}}
>
<div
className="grid grid-flow-col content-start gap-1 p-3"
style={{ gridTemplateRows: 'repeat(auto-fill, minmax(84px, max-content))', maxHeight: '100%' }}
>
{apps.map((app) => (
<DesktopIcon
key={app.id}
app={app}
selected={selected === app.id}
onSelect={() => setSelected(app.id)}
onOpen={() => openApp(app.id)}
/>
))}
<div className="absolute inset-0" aria-label="Desktop app grid">
{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 (
<DesktopIcon
key={app.id}
app={app}
selected={selected === app.id}
dragging={dragging === app.id}
pickedUp={picked === app.id}
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;
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)}
/>
);
})}
</div>
<WindowLayer />
@@ -54,6 +174,9 @@ export function Desktop() {
<ContextMenuContent className="w-52">
<ContextMenuItem onSelect={() => openApp('feed')}>Open Feed</ContextMenuItem>
<ContextMenuItem onSelect={() => openApp('settings')}>Open Settings</ContextMenuItem>
<ContextMenuItem onSelect={() => { reset('desktop'); setAnnouncement('Desktop icon layout reset.'); }}>
Reset desktop icon layout
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem disabled={windows.length === 0} onSelect={minimizeAll}>
Minimize all windows
@@ -62,6 +185,8 @@ export function Desktop() {
Close all windows
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</ContextMenu>
<span id="icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
</>
);
}

View File

@@ -6,17 +6,40 @@ interface DesktopIconProps {
selected: boolean;
onSelect: () => void;
onOpen: () => void;
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => 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 (
<button
type="button"
data-icon-id={app.id}
tabIndex={tabIndex}
style={style}
onPointerDown={onPointerDown}
onClick={onSelect}
onDoubleClick={onOpen}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
@@ -24,10 +47,13 @@ export function DesktopIcon({ app, selected, onSelect, onOpen }: DesktopIconProp
}}
aria-label={`${app.title}${app.description}`}
className={cn(
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-colors',
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-[background-color,transform,box-shadow] motion-reduce:transition-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
selected ? 'bg-primary/15' : 'hover:bg-foreground/5',
selected || pickedUp ? 'bg-primary/15' : 'hover:bg-foreground/5',
dragging && 'scale-105 cursor-grabbing shadow-lg',
)}
aria-pressed={pickedUp || undefined}
aria-describedby={pickedUp ? 'icon-layout-status' : undefined}
>
<span
className={cn(

View File

@@ -1,4 +1,4 @@
import { Suspense, useCallback, useMemo, useState } from 'react';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronLeft, LayoutGrid, Zap } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ErrorBoundary } from '@/components/ErrorBoundary';
@@ -16,6 +16,9 @@ import { useWindowManager } from '@/os/useWindowManager';
import { desktopApps, getApp } from '@/os/registry';
import { cn } from '@/lib/utils';
import type { AppParams } from '@/os/types';
import { useIconLayout } from '@/os/useIconLayout';
const MOBILE_DRAG_THRESHOLD = 8;
/**
* On a phone the window metaphor only gets in the way, so the same apps and
@@ -155,15 +158,138 @@ export function MobileAppShell() {
}
function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
const apps = desktopApps();
const [columns, setColumns] = useState(() => window.innerWidth < 480 ? 3 : 4);
const [dragging, setDragging] = useState<string | null>(null);
const [target, setTarget] = useState<string | null>(null);
const [picked, setPicked] = useState<string | null>(null);
const [pickedOrder, setPickedOrder] = useState<string[] | null>(null);
const [announcement, setAnnouncement] = useState('');
const dragStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
const suppressClick = useRef(false);
const targetRef = useRef<string | null>(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<typeof app> => Boolean(app));
useEffect(() => {
const onResize = () => setColumns(window.innerWidth < 480 ? 3 : 4);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const reorder = useCallback((id: string, beforeId: string | null) => {
setMobile((order) => {
const without = order.filter((item) => item !== id);
const index = beforeId ? without.indexOf(beforeId) : without.length;
const next = [...without];
next.splice(index < 0 ? next.length : index, 0, id);
return next;
});
const position = beforeId ? Math.max(1, layout.mobile.indexOf(beforeId) + 1) : layout.mobile.length;
setAnnouncement(`${id} moved to position ${position}.`);
}, [layout.mobile, setAnnouncement, setMobile]);
useEffect(() => {
const onMove = (event: PointerEvent) => {
const active = dragStart.current;
if (!active) return;
if (!active.moved && Math.hypot(event.clientX - active.x, event.clientY - active.y) < MOBILE_DRAG_THRESHOLD) return;
active.moved = true;
setDragging(active.id);
const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-home-icon-id]');
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);
window.addEventListener('pointerup', onEnd);
window.addEventListener('pointercancel', onEnd);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onEnd);
window.removeEventListener('pointercancel', onEnd);
};
}, [reorder]);
const onKeyDown = (id: string, event: React.KeyboardEvent<HTMLButtonElement>) => {
const index = layout.mobile.indexOf(id);
if (event.key === 'Escape' && picked) {
event.preventDefault();
if (pickedOrder) setMobile(() => pickedOrder);
setPicked(null);
setPickedOrder(null);
setAnnouncement('Move cancelled and original position restored.');
return;
}
if (event.key === ' ' || (event.key === 'Enter' && picked === id)) {
event.preventDefault();
if (picked === id) {
setPicked(null);
setPickedOrder(null);
setAnnouncement(`${id} dropped at position ${index + 1}.`);
} else {
setPicked(id);
setPickedOrder(layout.mobile);
setAnnouncement(`${id} picked up. Use arrow keys to reorder, Enter to drop, Escape to cancel.`);
}
return;
}
if (!picked) return;
const offset = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : event.key === 'ArrowUp' ? -columns : event.key === 'ArrowDown' ? columns : 0;
if (!offset) return;
event.preventDefault();
const nextIndex = Math.max(0, Math.min(layout.mobile.length - 1, index + offset));
setMobile((order) => {
const without = order.filter((item) => item !== id);
const targetIndex = Math.max(0, Math.min(without.length, nextIndex));
const next = [...without];
next.splice(targetIndex, 0, id);
return next;
});
setAnnouncement(`${id} moved to position ${nextIndex + 1}.`);
};
return (
<div className="os-desktop-surface h-full overflow-y-auto p-6">
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4">
{desktopApps().map((app) => (
<div className="grid grid-cols-3 gap-4 min-[480px]:grid-cols-4">
{orderedApps.map((app) => (
<button
key={app.id}
type="button"
onClick={() => onOpen(app.id)}
className="flex flex-col items-center gap-2 rounded-xl p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95"
data-home-icon-id={app.id}
aria-pressed={picked === app.id || undefined}
aria-describedby={picked === app.id ? 'mobile-icon-layout-status' : undefined}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
}}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
onOpen(app.id);
}}
onKeyDown={(event) => onKeyDown(app.id, event)}
className={cn(
'flex flex-col items-center gap-2 rounded-xl p-2 transition-[transform,background-color,box-shadow] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95',
dragging === app.id && 'scale-105 bg-primary/15 shadow-lg',
target === app.id && dragging !== app.id && 'bg-primary/10 ring-2 ring-primary/50',
picked === app.id && 'bg-primary/15',
)}
>
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
<app.icon className="size-7 text-primary" aria-hidden />
@@ -172,6 +298,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
</button>
))}
</div>
<span id="mobile-icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
</div>
);
}

129
src/os/iconLayout.test.ts Normal file
View File

@@ -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);
});
});

133
src/os/iconLayout.ts Normal file
View File

@@ -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<string>, geometry: GridGeometry): Omit<DesktopSlot, 'id'> {
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<string>();
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<string>();
const occupied = new Set<string>();
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<string>();
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, 'id'>): 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;

81
src/os/useIconLayout.ts Normal file
View File

@@ -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<IconLayout>(() => 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<IconLayout>(() => ({
desktop: reconcileDesktopLayout(layout.desktop, stableIds, stableGeometry),
mobile: reconcileMobileLayout(layout.mobile, stableIds),
}), [layout, stableGeometry, stableIds]);
const dirty = useRef(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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]);
}