diff options
| -rw-r--r-- | notes/pinned-tiles-plan.md | 129 | ||||
| -rw-r--r-- | src/features/workspaces/index.ts | 7 | ||||
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.test.ts | 39 | ||||
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.ts | 19 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.test.ts | 428 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 335 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.svelte.ts | 61 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 77 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeHeader.svelte | 2 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeHeader.test.ts | 2 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.svelte | 318 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.test.ts | 43 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 2 |
13 files changed, 1194 insertions, 268 deletions
diff --git a/notes/pinned-tiles-plan.md b/notes/pinned-tiles-plan.md new file mode 100644 index 0000000..6d50e55 --- /dev/null +++ b/notes/pinned-tiles-plan.md @@ -0,0 +1,129 @@ +# Pinned tiles + displacement reflow — plan + +Feature: home tiles can be dropped onto **specific cells** of the grid; empty cells are +real estate that persists. Dragging shows a live **FLIP** reflow preview; Escape/cancel +returns everything to the committed layout. + +## Interaction semantics (user-specified, literal) + +The grid's cells form a fixed stream (wide: column-major over 4 rows; tall: row-major +over 2 columns). Only **filled** cells flow. Insertion at a cell pushes the contiguous +run of filled cells **forward** (in stream order) until the first empty cell absorbs it: + +- `X _ X` + place Y at slot 1 → `Y X X` (X shifts into the gap; the second X never moves) +- `_ X Z _` + place Y at slot 2 → `_ Y X Z` (X and Z each shift one; leading gap kept) + +Rules that follow (and are deliberately uniform): + +1. **Gaps never collapse.** Removing a tile opens a gap at its anchor; nothing slides + backward to fill it. So an internal move = remove (gap opens) + insert (run pushes + forward): `X Y Z`, drag X to slot 3 → `_ Y X Z`. The gap at slot 1 is preserved — + consistent with the two examples above. +2. **Preview = the exact post-drop state, recomputed from the COMMITTED layout on every + hover-cell change** ("as if the user directly moved from the original to there" — no + accumulation of intermediate previews). The animation, however, runs from wherever + tiles currently are on screen (mid-flight included) to the new targets — re-entrant + FLIP. +3. **Escape / pointercancel** discards the preview: the dragged tile flies back to its + committed anchor and everything FLIPs home. Nothing persists. +4. During a drag: a **faint dot** marks the center of every unit cell (incl. one extra + pitch of slack past the content end), and the **dragged tile element itself follows + the pointer** (lifted: scale + shadow, no pointer events). A dashed ghost footprint + marks the landing anchor. + +## Data model (v2) + +```ts +TileLayoutEntry { id, colSpan, rowSpan, order, col?, row? } // col/row: pinned anchor +TileLayoutData { version: 2, tiles } // sanitize migrates v1→v2 +``` + +- Anchors are persisted in a **universal 4-row frame** (wide's frame). Tall normalizes: + render `{ c, r }` ↔ universal `{ col: 1, row: 2(r−1)+c }`. Wide is identity. + A pin outside tall's 2 columns reads as **unpinned** in tall (render-time clamp — same + philosophy as spans). Round-trips can be lossy in that ragged case; accepted. +- `col`/`row` absent = **unpinned** → flows into the first free cells at render time. +- `order` remains: arrival order for unpinned flow + first-wins tie-breaks. Sort-by-recent + now also **clears pins** (an explicit arrangement reset). +- v1 documents load as all-unpinned (render identical to today) and write back as v2 on + the next mutation. No version sniffing outside `sanitizeLayout`. + +## Pure ops (`logic/tile-layout.ts`) + +- `pack(entries, geometry)` — v2: place in-range pinned tiles first (universal→render + anchor; out-of-range/overlapping/span-unfit → deferred to unpinned), then flow unpinned + by first-fit. One pass, no solver; first-pinned-wins, strictly one-ply (a displaced pin + never bumps another pin). +- `displacePlace(placements, draggedId, cell, geometry)` — the render-frame core: + remove the dragged tile (gap opens), force-anchor it at `cell`, then walk the others in + stream order: keep if free, else re-anchor at the next fitting slot **forward** of the + conflict (never backward). Used for BOTH the live preview (on packed placements) and, + via `placeAtCell`, the commit. +- `placeAtCell(entries, id, cell, geometry)` — commit path: pack → displacePlace → + pin back to entries (universal-frame anchors; persisted spans kept raw, anchors come + from the render placements). +- `pinAtFirstEmpty(entries, id, geometry)` — the create flow: pin the tile at the first + truly-empty slot (holes now count). +- `firstEmptySlot` — unchanged contract, now hole-aware by construction. +- `sortByRecentActivity` — renumbers order AND clears pins. + +## Store (`tile-layout-store.svelte.ts`) + +- `move(id, toIndex)` → replaced by `placeAtCell(id, cell, geometry)`. +- Structural equality (`sameEntries`) gains `col`/`row`. + +## Drag (`logic/drag-controller.ts`) + +- `targetIndex: number` → `targetCell: { col, row } | null` (pure rename; semantics of + the state machine — tap/lift/drop/cancel, touch scroll-wins — unchanged). + +## Component (`HomeTileGrid.svelte`) + +- **Hit math** replaces `elementFromPoint`: cell = `floor((pointer − gridRect.origin) / + pitch) + 1` (pitch = unit + gap), clamped to ≥ 1 and to the fixed cross-axis. The gap + between cells maps to the preceding cell. The pointer always resolves to a cell during + a drag (clamped), so empty cells are drop targets. +- **Dots overlay**: while dragging, the grid gets a `radial-gradient` background + (2px dot, `base-content` at low opacity) with `background-size = pitch × pitch` and + `background-position = unit/2` so dots sit at cell centers; `padding` extends one pitch + past the content end so the append-slot has a dot. Pure CSS, no extra DOM. +- **Dragged tile follows the pointer**: the wrapper keeps its committed grid slot in the + layout; a `translate(pointer − grabOffset) scale(1.05)` transform (via gsap) lifts it + out visually; `pointer-events: none` + pointer capture keep the events flowing. +- **Ghost**: dashed outline (dragged tile's tone) at the preview anchor, footprint-sized. +- **FLIP**: GSAP `Flip` plugin. Capture in `$effect.pre` (state of all tile elements + except the dragged/settling one), mutate (preview placements / committed placements), + `Flip.from(state, …)` in `$effect` after the DOM update. Re-entrancy comes free: + `Flip.getState` records in-flight transforms, so each hover change animates from the + current visual position — while the preview itself is always recomputed from COMMITTED + (semantics vs. motion stay separate, per the user's requirement). +- **Drop**: commit `placeAtCell`, then settle the tile from its pointer transform to + identity (gsap, ~150ms) — it lands exactly where the ghost was. +- **Escape**: window `keydown` in the CAPTURE phase while dragging (beats the page-level + Escape→deselect handler, `stopPropagation`): dispatch `cancel` → preview discards → + tiles FLIP home → dragged tile gsap-animates from the pointer back to its anchor. + +## Out of scope / non-goals + +- No backward displacement (runs only push forward); no multi-tile drag; no touch + auto-scroll while dragging (existing scroll-wins semantics stay). +- The resize picker is unchanged: resizing a pinned tile may push it to unpinned flow if + the footprint no longer fits its anchor (render-time rule — persisted spans untouched). + +## Files + +1. `src/features/workspaces/logic/tile-layout.ts` (+ `tile-layout.test.ts`) — the bulk +2. `src/features/workspaces/tile-layout-store.svelte.ts` (+ test) +3. `src/features/workspaces/logic/drag-controller.ts` (+ test) — mechanical targetCell rename +4. `src/features/workspaces/ui/HomeTileGrid.svelte` — hit math, dots, follow-pointer, ghost, Flip +5. `src/features/workspaces/ui/WorkspacesHome.svelte` (+ test) — wire placeAtCell; sort label +6. `src/features/workspaces/ui/HomeHeader.svelte` — sort label copy + +## Verification + +- New pure tests: v2 sanitize/migration, pinned pack (incl. wide↔tall mapping, overlap + first-wins, out-of-range → flow), displacement (both user examples + internal moves + + span cases), pin round-trip, hole-aware firstEmptySlot, store equality. +- Component tests: Escape cancels a drag without persisting; drop commits `placeAtCell`. +- `bun run typecheck && bun run test && bun run build`; biome clean on touched files. +- Headless screenshots (wide + tall) of a pinned layout with gaps. diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index 0aa7fca..243357e 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -23,17 +23,20 @@ export type { } from "./logic/tile-layout"; export { clampSpan, + displacePlace, fillOrderKey, firstEmptySlot, geometryFor, - insertAtFirstEmptySlot, mergeLayout, - moveTile, pack, + pinAtFirstEmpty, + placeAtCell, resizeTile, sanitizeLayout, sortByRecentActivity, sortTiles, + toRenderCell, + toUniversalCell, } from "./logic/tile-layout"; export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts index d6dd760..459063a 100644 --- a/src/features/workspaces/logic/drag-controller.test.ts +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -26,7 +26,7 @@ describe("nextDragPhase — mouse", () => { it("movement past the threshold starts a drag", () => { const transition = nextDragPhase( press(), - { type: "pointer-move", x: 20, y: 12, targetIndex: 3 }, + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ @@ -34,7 +34,7 @@ describe("nextDragPhase — mouse", () => { id: "t1", x: 20, y: 12, - targetIndex: 3, + targetCell: { col: 4, row: 1 }, touch: false, }); expect(transition.emit).toBeUndefined(); @@ -43,7 +43,7 @@ describe("nextDragPhase — mouse", () => { it("movement within the threshold stays pressing", () => { const transition = nextDragPhase( press(), - { type: "pointer-move", x: 12, y: 14, targetIndex: 0 }, + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); @@ -58,16 +58,16 @@ describe("nextDragPhase — mouse", () => { it("release while dragging emits a drop with the last tracked index", () => { let transition: DragTransition = nextDragPhase( press(), - { type: "pointer-move", x: 30, y: 30, targetIndex: 2 }, + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, OPTS, ); transition = nextDragPhase( transition.phase, - { type: "pointer-move", x: 31, y: 31, targetIndex: 5 }, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, OPTS, ); transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); - expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 }); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); expect(transition.phase).toEqual({ kind: "idle" }); }); }); @@ -81,7 +81,7 @@ describe("nextDragPhase — touch", () => { id: "t1", x: 10, y: 10, - targetIndex: -1, + targetCell: null, touch: true, }); }); @@ -89,7 +89,7 @@ describe("nextDragPhase — touch", () => { it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { const transition = nextDragPhase( press(true), - { type: "pointer-move", x: 10, y: 80, targetIndex: 0 }, + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ kind: "idle" }); @@ -99,7 +99,7 @@ describe("nextDragPhase — touch", () => { it("movement within the threshold before the lift keeps pressing", () => { const transition = nextDragPhase( press(true), - { type: "pointer-move", x: 13, y: 12, targetIndex: 0 }, + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase?.kind).toBe("pressing"); @@ -109,7 +109,7 @@ describe("nextDragPhase — touch", () => { let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); transition = nextDragPhase( transition.phase, - { type: "pointer-move", x: 10, y: 90, targetIndex: 4 }, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ @@ -117,7 +117,7 @@ describe("nextDragPhase — touch", () => { id: "t1", x: 10, y: 90, - targetIndex: 4, + targetCell: { col: 5, row: 1 }, touch: true, }); }); @@ -131,7 +131,7 @@ describe("nextDragPhase — touch", () => { it("release while lifted (no movement) drops at the lifted tile's own slot", () => { let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); - expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: -1 }); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); }); }); @@ -140,7 +140,11 @@ describe("nextDragPhase — cancellations", () => { for (const phase of [ { kind: "idle" as const }, press(), - nextDragPhase(press(), { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, OPTS).phase, + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, ]) { const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); expect(transition.phase).toEqual({ kind: "idle" }); @@ -153,8 +157,11 @@ describe("nextDragPhase — cancellations", () => { kind: "idle", }); expect( - nextDragPhase({ kind: "idle" }, { type: "pointer-move", x: 1, y: 1, targetIndex: 0 }, OPTS) - .phase, + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, ).toEqual({ kind: "idle" }); expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ kind: "idle", @@ -164,7 +171,7 @@ describe("nextDragPhase — cancellations", () => { it("a second pointer-down restarts the gesture with the new tile", () => { const dragging = nextDragPhase( press(), - { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, OPTS, ).phase; const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts index 03dcade..a1877aa 100644 --- a/src/features/workspaces/logic/drag-controller.ts +++ b/src/features/workspaces/logic/drag-controller.ts @@ -12,9 +12,11 @@ * so the two gestures never fight. Release while lifted = DROP. * * Pure: `(phase, event, options) → { phase, emit }`. The component owns the - * reactive `$state` and performs the emits (navigate / select / reorder). + * reactive `$state` and performs the emits (navigate / select / place). */ +import type { Cell } from "./tile-layout"; + /** Where the interaction currently stands. */ export type DragPhase = | { readonly kind: "idle" } @@ -32,7 +34,8 @@ export type DragPhase = readonly id: string; readonly x: number; readonly y: number; - readonly targetIndex: number; + /** The grid CELL under the pointer (null = not over the canvas yet). */ + readonly targetCell: Cell | null; readonly touch: boolean; }; @@ -40,7 +43,7 @@ export type DragPhase = export type DragEmit = | { readonly kind: "tap"; readonly id: string } | { readonly kind: "lift"; readonly id: string } - | { readonly kind: "drop"; readonly id: string; readonly targetIndex: number }; + | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null }; /** Events the component dispatches (DOM events + the long-press timer firing). */ export type DragEvent = @@ -55,7 +58,7 @@ export type DragEvent = readonly type: "pointer-move"; readonly x: number; readonly y: number; - readonly targetIndex: number; + readonly targetCell: Cell | null; } | { readonly type: "pointer-up" } | { readonly type: "long-press" } @@ -105,7 +108,7 @@ export function nextDragPhase( id: phase.id, x: event.x, y: event.y, - targetIndex: event.targetIndex, + targetCell: event.targetCell, touch: phase.touch, }, }; @@ -116,7 +119,7 @@ export function nextDragPhase( ...phase, x: event.x, y: event.y, - targetIndex: event.targetIndex, + targetCell: event.targetCell, }, }; } @@ -130,7 +133,7 @@ export function nextDragPhase( if (phase.kind === "dragging") { return { phase: IDLE, - emit: { kind: "drop", id: phase.id, targetIndex: phase.targetIndex }, + emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell }, }; } return { phase }; @@ -145,7 +148,7 @@ export function nextDragPhase( id: phase.id, x: phase.x, y: phase.y, - targetIndex: -1, + targetCell: null, touch: true, }, emit: { kind: "lift", id: phase.id }, diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts index be61da4..81156e7 100644 --- a/src/features/workspaces/logic/tile-layout.test.ts +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -1,18 +1,23 @@ import { describe, expect, it } from "vitest"; import { clampSpan, + displacePlace, fillOrderKey, firstEmptySlot, type GridGeometry, geometryFor, - insertAtFirstEmptySlot, mergeLayout, - moveTile, + type Placement, pack, + pinAtFirstEmpty, + placeAtCell, resizeTile, sanitizeLayout, sortByRecentActivity, type TileLayoutEntry, + toRenderCell, + toUniversalCell, + UNIVERSAL_ROWS, } from "./tile-layout"; const WIDE = geometryFor(1920, 1080); @@ -237,13 +242,13 @@ describe("firstEmptySlot", () => { }); }); -describe("insertAtFirstEmptySlot", () => { - it("inserts a new tile so it packs into the first empty slot", () => { +describe("pinAtFirstEmpty", () => { + it("pins a new tile at the first empty slot (holes count)", () => { // A double-height tile fills rows 1-2, a single fills row 3 → the first - // empty slot is (col1,row4); the new tile must land exactly there. + // empty slot is (col1,row4); the new tile PINS exactly there. const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; - const next = insertAtFirstEmptySlot(tiles, "new", WIDE); - expect(next.map((t) => t.id)).toEqual(["big", "c", "new"]); + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); const placements = pack(next, WIDE); expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ ["big", 1, 1], @@ -252,31 +257,43 @@ describe("insertAtFirstEmptySlot", () => { ]); }); - it("renumbers orders densely after the insert", () => { - const next = insertAtFirstEmptySlot([tile("a", 0, 1, 2), tile("b", 7)], "new", WIDE); - expect(next.map((t) => t.order)).toEqual([0, 1, 2]); + it("pins into a GAP between pinned tiles", () => { + // big is pinned out of the way; the flow left row 3 free for a 1×1. + const tiles = [ + { ...tile("big", 0, 1, 2), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 }); }); - it("moves an already-present tile instead of duplicating (refresh-race idempotency)", () => { + it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => { const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)]; - const first = insertAtFirstEmptySlot(tiles, "x", WIDE); - const second = insertAtFirstEmptySlot(first, "x", WIDE); + const first = pinAtFirstEmpty(tiles, "x", WIDE); + const second = pinAtFirstEmpty(first, "x", WIDE); expect(second).toEqual(first); expect(second.filter((t) => t.id === "x")).toHaveLength(1); }); - it("appends when the empty slot is beyond the current extent", () => { + it("appends (pins beyond the current extent) when the grid is full", () => { const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; - const next = insertAtFirstEmptySlot(tiles, "e", WIDE); - expect(next.map((t) => t.id)).toEqual(["a", "b", "c", "d", "e"]); - expect(pack(next, WIDE).at(-1)).toMatchObject({ id: "e", col: 2, row: 1 }); + const next = pinAtFirstEmpty(tiles, "e", WIDE); + expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 }); }); - it("preserves the spans of a moved existing tile", () => { + it("preserves the spans of a re-pinned existing tile", () => { const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; - const next = insertAtFirstEmptySlot(tiles, "x", WIDE); + const next = pinAtFirstEmpty(tiles, "x", WIDE); expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 }); }); + + it("saves the pin in the universal frame (tall)", () => { + const tiles = [{ ...tile("a", 0), col: 1, row: 1 }]; + const next = pinAtFirstEmpty(tiles, "new", TALL); + // a renders at tall (1,1); the first empty slot is tall (2,1) → universal + // (col 1, row 2). + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 }); + }); }); describe("resizeTile", () => { @@ -304,31 +321,203 @@ describe("resizeTile", () => { }); }); -describe("moveTile", () => { - it("moves a tile forward, landing after the hovered tile", () => { - const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "a", 2); - expect(next.map((t) => t.id)).toEqual(["b", "c", "a", "d"]); +describe("displacePlace", () => { + /** Render placements as the column-1 stream (cells 1..n, `_` = empty, + * one character per tile — the id's first char). */ + const stream = (placements: Placement[], geometry: GridGeometry): string => { + const cells = new Map<number, string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0)); + } + } + } + const maxKey = Math.max(0, ...cells.keys()); + const out: string[] = []; + for (let key = 0; key <= maxKey; key++) { + out.push(cells.get(key) ?? "_"); + } + return out.join(""); + }; + + it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => { + // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in + // from outside the shown range — its origin is beyond the gap). + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["Y", 1, 1], + ["X", 1, 2], + ["X2", 1, 3], + ]); + }); + + it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => { + // X@(1,2), Z@(1,3); Y dragged in from cell 5. + const placements: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["Y", 1, 2], + ["X", 1, 3], + ["Z", 1, 4], + ]); + }); + + it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => { + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + ]; + // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. + const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 1, 3], + ]); + }); + + it("internal move rightward over a full run pushes the tail forward", () => { + // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 2], + ["b", 1, 3], + ["c", 1, 4], + ]); + }); + + it("an unknown dragged id changes nothing (a copy of the input)", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE); + expect(next).toEqual(placements); + }); + + it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => { + // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2): + // its own old body is free-ish but (1,2)+(1,3) collides with tail's + // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1 + // anymore → column 2. + const placements: Placement[] = [ + { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["big", 1, 2, 2], + ["tail", 2, 1, 2], + ]); }); - it("moves a tile backward, landing before the hovered tile", () => { - const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "d", 1); - expect(next.map((t) => t.id)).toEqual(["a", "d", "b", "c"]); + it("displacing into the NEXT COLUMN when the column is full", () => { + // 4 tiles fill column 1; drag the first to the LAST cell of column 1. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); + // d is pushed past the column's end → column 2 row 1. Stream order: + // b(1) c(2) a(3) d(4). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["c", 1, 3], + ["a", 1, 4], + ["d", 2, 1], + ]); }); - it("renumbers orders densely", () => { - const next = moveTile([tile("a", 0), tile("b", 3), tile("c", 9)], "c", 0); - expect(next.map((t) => t.order)).toEqual([0, 1, 2]); + it("works in TALL mode along the row-major stream", () => { + // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1). + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL); + // b pushed forward past the row's end → next row. Stream order: a(1) b(2). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 2, 1], + ["b", 1, 2], + ]); }); - it("is a no-op for the same index or an unknown id", () => { + it("the stream helper renders the documented examples (sanity)", () => { + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + expect(stream(next, WIDE)).toBe("YXX"); + const placements2: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE); + expect(stream(next2, WIDE)).toBe("_YXZ"); + }); +}); + +describe("placeAtCell", () => { + it("pins every tile at its displaced anchor in the universal frame", () => { + // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its + // cell (the gap at (1,1) opens and stays). const tiles = [tile("a", 0), tile("b", 1)]; - expect(moveTile(tiles, "a", 0)).toEqual(tiles); - expect(moveTile(tiles, "x", 1)).toEqual(tiles); + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // And the pinned layout packs exactly as placed (b first — stream order). + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 2, 1], + ]); }); - it("clamps out-of-range indices", () => { - const next = moveTile([tile("a", 0), tile("b", 1)], "a", 99); - expect(next.map((t) => t.id)).toEqual(["b", "a"]); + it("keeps persisted spans raw (never clamped) through the commit", () => { + // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps + // its raw 4-wide span. + const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)]; + const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL); + expect(next.find((t) => t.id === "big")).toMatchObject({ + colSpan: 4, + rowSpan: 1, + col: 1, + row: 5, + }); + }); + + it("renumbers orders densely in stream order", () => { + const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)]; + const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE); + expect(next.every((t, i) => t.order === i)).toBe(true); + }); + + it("maps tall commits to the universal frame", () => { + // Tall (col 2, row 1) → universal (col 1, row 2). + const tiles = [tile("a", 0)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 }); + }); + + it("an unknown id changes nothing (no-op)", () => { + const tiles = [tile("a", 0)]; + expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles); }); }); @@ -355,6 +544,26 @@ describe("sortByRecentActivity", () => { ["b", 1], ]); }); + + it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ]), + ); + expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + // And the now-unpinned layout packs compactly. + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 1], + ["a", 1, 2], + ]); + }); }); describe("mergeLayout", () => { @@ -411,20 +620,32 @@ describe("mergeLayout", () => { }); describe("sanitizeLayout", () => { - it("accepts a valid document", () => { + it("accepts a valid v2 document (anchors kept)", () => { + expect( + sanitizeLayout({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }); + }); + + it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => { expect( sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), ).toEqual({ - version: 1, + version: 2, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], }); }); it("rejects foreign versions and malformed documents", () => { - expect(sanitizeLayout(null)).toEqual({ version: 1, tiles: [] }); - expect(sanitizeLayout("nope")).toEqual({ version: 1, tiles: [] }); - expect(sanitizeLayout({ version: 2, tiles: [] })).toEqual({ version: 1, tiles: [] }); - expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 1, tiles: [] }); + expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] }); }); it("coerces malformed entries and drops anonymous/duplicate ones", () => { @@ -443,10 +664,30 @@ describe("sanitizeLayout", () => { { id: "b", colSpan: 3, rowSpan: 1, order: 2 }, ]); }); + + it("drops garbage anchors (non-positive / fractional / half a pair)", () => { + const data = sanitizeLayout({ + version: 2, + tiles: [ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4 }, + ]); + }); }); describe("geometry-agnostic persistence", () => { - it("the SAME persisted data renders in either geometry (positions re-derived, never stored)", () => { + it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => { const tiles: TileLayoutEntry[] = [ tile("a", 0, 4, 2), tile("b", 1), @@ -460,11 +701,112 @@ describe("geometry-agnostic persistence", () => { expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 }); expect(widePlacements.at(-1)).toMatchObject({ id: "e" }); expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 }); - // The document carries NO orientation/position field — only id/spans/order. + // An unpinned entry carries NO anchor — only id/spans/order. expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); }); }); +describe("pinned pack (v2)", () => { + it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 3 }, + { ...tile("b", 1), col: 1, row: 1 }, + tile("c", 2), // unpinned → first free = (1,2) + tile("d", 3), // unpinned → next free = (1,4) + ]; + // pack returns ENTRY order (a, b, c, d); positions are what matters. + const placements = pack(tiles, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 1], + ["c", 1, 2], + ["d", 1, 4], + ]); + }); + + it("keeps the GAP between two pins (flow does not compact it away)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([ + ["a", 1], + ["z", 4], + ]); + }); + + it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => { + // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that + // anchor → it flows to the first fitting slot instead: (1,2). + const tiles: TileLayoutEntry[] = [ + { ...tile("big", 0, 1, 2), col: 1, row: 4 }, + { ...tile("a", 1), col: 1, row: 1 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 }); + }); + + it("overlapping pins resolve first-wins by entry order (the loser flows)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 2 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 }); + expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 }); + }); + + it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => { + const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }]; + expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 }); + }); + + it("tall pins map through the universal frame in both directions", () => { + // Universal column 1 folds onto tall's 2 columns in reading order: + // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2). + expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 }); + expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 }); + expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 }); + expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 }); + // Wide is the identity. + expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(UNIVERSAL_ROWS).toBe(4); + }); + + it("a layout dragged in TALL renders its pins when back in WIDE", () => { + // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) → + // universal (1,2). Back in wide they render exactly there (col 1). + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 2], + ]); + }); + + it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => { + const persisted = { + version: 2 as const, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + { ...tile("b", 2), col: 1, row: 3 }, + ] as TileLayoutEntry[], + }; + const merged = mergeLayout(persisted, ["a", "b"]); + expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 3], + ]); + // The gap at (1,2) stays: a new workspace flows/pins INTO it. + expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 }); + }); +}); + describe("type surface", () => { it("exposes the geometry fields the picker and grid need", () => { // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts index 4d81a0c..4b5c89c 100644 --- a/src/features/workspaces/logic/tile-layout.ts +++ b/src/features/workspaces/logic/tile-layout.ts @@ -2,13 +2,16 @@ * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero * DOM, zero Svelte, zero effects. * - * The PERSISTED data is orientation-independent: `{ version, tiles: [{ id, - * colSpan, rowSpan, order }] }` — the manual tile order + requested spans - * ONLY, never positions. Positions are re-derived by {@link pack} from the - * CURRENT geometry, so the same data renders in either geometry (wide desktop - * / tall mobile) with no orientation conversion step: a tile whose persisted - * span exceeds the geometry's cap simply renders clamped (the persisted span - * is untouched). + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). * * Terminology (distinct from the layout feature's nested split-tree "tile"): * a HOME TILE is one workspace's cell in the flat start-screen grid. @@ -55,19 +58,28 @@ export interface GridGeometry { readonly margin: number; } -/** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */ +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ export interface TileLayoutEntry { /** The workspace id (the slug). */ readonly id: string; readonly colSpan: number; readonly rowSpan: number; - /** Lower sorts earlier. Positions are NEVER persisted — only this order. */ + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; } /** The persisted document (localStorage via the injected `LocalStore` adapter). */ export interface TileLayoutData { - readonly version: 1; + readonly version: 1 | 2; readonly tiles: readonly TileLayoutEntry[]; } @@ -187,6 +199,37 @@ export function fillOrderKey(col: number, row: number, geometry: GridGeometry): return (row - 1) * geometry.flowCols + (col - 1); } +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + function cellsFree( occupied: ReadonlySet<string>, col: number, @@ -260,24 +303,180 @@ function firstFit( } /** - * THE order → coordinates transform: place the tiles (in their persisted - * order) into the first fitting slot in the geometry's fill order — greedy - * first-fit, so every fitting slot is used before the flow advances (no - * intentional holes). Requested spans clamp to the geometry (a 3×1 desktop - * tile in tall mode packs as 2×1 — the persisted span is untouched). Pure: - * entries + geometry in, a NEW placements array out (in pack order, which - * respects the persisted order). + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). */ export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { const occupied = new Set<string>(); - const placements: Placement[] = []; - for (const tile of sortTiles(tiles)) { + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { const { colSpan, rowSpan } = clampSpan(tile, geometry); const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); occupy(occupied, slot.col, slot.row, colSpan, rowSpan); - placements.push({ id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; } - return placements; + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out (stream order). + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + return placed.sort( + (a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry), + ); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); } /** @@ -309,28 +508,33 @@ function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { } /** - * Insert (or MOVE an already-present tile to) the first empty slot in fill - * order — the header-create placement. The new tile is 1×1, and inserting it - * before the first placement that starts AFTER the empty slot makes greedy - * packing drop it exactly into that slot. Idempotent: an id already in the - * layout is moved (spans preserved), not duplicated — this closes the race - * where the list-refresh sync appended the id first. Pure: entries in, a NEW - * entries array out (renumbered). + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). */ -export function insertAtFirstEmptySlot( +export function pinAtFirstEmpty( tiles: readonly TileLayoutEntry[], id: string, geometry: GridGeometry, ): TileLayoutEntry[] { const without = sortTiles(tiles).filter((tile) => tile.id !== id); const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); - const slotKey = fillOrderKey(slot.col, slot.row, geometry); - const placements = pack(without, geometry); - let index = placements.findIndex((p) => fillOrderKey(p.col, p.row, geometry) > slotKey); - if (index === -1) index = without.length; + const anchor = toUniversalCell(slot, geometry); const existing = tiles.find((tile) => tile.id === id); - const entry: TileLayoutEntry = existing ?? { id, colSpan: 1, rowSpan: 1, order: 0 }; - return renumber([...without.slice(0, index), entry, ...without.slice(index)]); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; } /** @@ -355,31 +559,10 @@ export function resizeTile( } /** - * Move one tile to a position in the manual ORDER (the drag-reorder op). The - * target index is in the CURRENT order-sorted array; the tile is removed and - * re-inserted at the index, so dragging forward lands after the hovered tile - * and dragging backward lands before it (the dragged tile takes the hovered - * slot). Positions re-derive from the new order — never stored. Pure. - */ -export function moveTile( - tiles: readonly TileLayoutEntry[], - id: string, - toIndex: number, -): TileLayoutEntry[] { - const ordered = sortTiles(tiles); - const from = ordered.findIndex((tile) => tile.id === id); - if (from === -1 || toIndex === from) return ordered; - const without = ordered.filter((tile) => tile.id !== id); - const index = Math.max(0, Math.min(Math.floor(toIndex), without.length)); - const moved = ordered[from]; - if (!moved) return ordered; - return renumber([...without.slice(0, index), moved, ...without.slice(index)]); -} - -/** - * Re-apply the recent-activity order (the header overflow "Sort" action): - * tiles sort by their workspace's `lastActivityAt` descending (unknown ids - * last, ties stable), then orders renumber. Pure. + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. */ export function sortByRecentActivity( tiles: readonly TileLayoutEntry[], @@ -388,7 +571,12 @@ export function sortByRecentActivity( const sorted = sortTiles(tiles).sort( (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), ); - return renumber(sorted); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); } /** @@ -429,14 +617,17 @@ export function mergeLayout( /** * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load - * is untyped at the boundary). Anything but `{ version: 1, tiles: [...] }` - * yields the empty layout; malformed entries coerce (missing spans → 1, - * garbage → dropped). Pure: unknown in, data out. + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. */ export function sanitizeLayout(raw: unknown): TileLayoutData { if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; const version = (raw as { version?: unknown }).version; - if (version !== 1) return EMPTY_LAYOUT; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; const rawTiles = (raw as { tiles?: unknown }).tiles; if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; const tiles: TileLayoutEntry[] = []; @@ -447,17 +638,21 @@ export function sanitizeLayout(raw: unknown): TileLayoutData { const id = (rawTile as { id?: unknown }).id; if (typeof id !== "string" || id === "" || seen.has(id)) continue; seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); tiles.push({ id, colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), }); } - return { version: 1, tiles }; + return { version: 2, tiles }; } -const EMPTY_LAYOUT: TileLayoutData = { version: 1, tiles: [] }; +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; function coerceSpan(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; @@ -468,3 +663,9 @@ function coerceOrder(value: unknown, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.floor(value); } + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts index ce01748..2ca66a4 100644 --- a/src/features/workspaces/tile-layout-store.svelte.ts +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -1,9 +1,10 @@ import type { LocalStore } from "../../adapters/local-storage"; import { + type Cell, type GridGeometry, - insertAtFirstEmptySlot, mergeLayout, - moveTile, + pinAtFirstEmpty, + placeAtCell, resizeTile, sanitizeLayout, sortByRecentActivity, @@ -12,41 +13,43 @@ import { } from "./logic/tile-layout"; /** - * The localStorage key for the home tile layout (order + spans only — never - * positions; positions re-derive from the geometry on every render). + * The localStorage key for the home tile layout (spans + optional pinned + * anchors in the universal 4-row frame — v2 documents; v1 loads migrate). */ export const TILE_LAYOUT_STORAGE_KEY = "dispatch.workspaces.tiles"; /** - * Tile-layout store — a thin reactive wrapper over the persisted - * orientation-independent layout document, persisted through the INJECTED - * `LocalStore` adapter (the repo's local-storage effect). All mutations go - * through the pure `logic/tile-layout` ops and write through to persistence. - * No ambient state: one instance per home page, created by the page entry - * (or injected in tests). + * Tile-layout store — a thin reactive wrapper over the persisted tile layout + * document, persisted through the INJECTED `LocalStore` adapter (the repo's + * local-storage effect). All mutations go through the pure `logic/tile-layout` + * ops and write through to persistence. No ambient state: one instance per + * home page, created by the page entry (or injected in tests). */ export interface TileLayoutStore { - /** The persisted entries, order-sorted (raw spans — NOT geometry-clamped). */ + /** The persisted entries, order-sorted (raw spans + universal-frame anchors). */ readonly entries: readonly TileLayoutEntry[]; /** * Merge the live workspace ids into the layout: workspaces unknown to the - * layout append at the end, entries for deleted workspaces drop. A no-op - * (no write) when the merge changes nothing — so an effect that tracks both - * the workspace list and this store settles instead of looping. + * layout append (unpinned — they flow to the first free cells), entries for + * deleted workspaces drop (their cells become gaps). A no-op (no write) when + * the merge changes nothing — so an effect that tracks both the workspace + * list and this store settles instead of looping. */ sync(workspaceIds: readonly string[]): void; /** Resize a tile (persisted spans; render-time clamping is separate). */ resize(id: string, colSpan: number, rowSpan: number): void; - /** Move a tile to a position in the manual order (drag reorder). */ - move(id: string, toIndex: number): void; /** - * Place (or move an already-present) tile into the FIRST EMPTY SLOT in the - * grid's fill order — the header-create flow. Idempotent (see - * `insertAtFirstEmptySlot`), closing the refresh race where `sync` appended - * the id first. + * Drop a tile at a specific CELL (the drag commit): the contiguous filled + * run pushes forward from that cell (gaps absorb it), every resulting + * position pins. See `placeAtCell`. + */ + placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void; + /** + * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create + * flow. Idempotent (see `pinAtFirstEmpty`). */ placeFirstEmpty(id: string, geometry: GridGeometry): void; - /** Re-apply the recent-activity order (the header overflow "Sort" action). */ + /** Re-apply the recent-activity order AND clear all pins (the reset). */ sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; } @@ -55,7 +58,9 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>): let data = $state<TileLayoutData>(sanitizeLayout(persistence.load())); function commit(tiles: readonly TileLayoutEntry[]): void { - data = { version: 1, tiles }; + // A pure op that changed nothing (e.g. a no-op place) doesn't write. + if (sameEntries(tiles, data.tiles)) return; + data = { version: 2, tiles }; persistence.save(data); } @@ -73,12 +78,12 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>): commit(resizeTile(data.tiles, id, colSpan, rowSpan)); }, - move(id, toIndex): void { - commit(moveTile(data.tiles, id, toIndex)); + placeAtCell(id, cell, geometry): void { + commit(placeAtCell(data.tiles, id, cell, geometry)); }, placeFirstEmpty(id, geometry): void { - commit(insertAtFirstEmptySlot(data.tiles, id, geometry)); + commit(pinAtFirstEmpty(data.tiles, id, geometry)); }, sortByRecentActivity(lastActivityById): void { @@ -87,7 +92,7 @@ export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>): }; } -/** Structural equality over entries (ids, spans, orders). */ +/** Structural equality over entries (ids, spans, orders, anchors). */ function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean { if (a.length !== b.length) return false; return a.every((entry, index) => { @@ -97,7 +102,9 @@ function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[ entry.id === other.id && entry.colSpan === other.colSpan && entry.rowSpan === other.rowSpan && - entry.order === other.order + entry.order === other.order && + entry.col === other.col && + entry.row === other.row ); }); } diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index f8b30f7..be6a14e 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -29,12 +29,21 @@ function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEn } describe("createTileLayoutStore", () => { - it("loads the persisted document at creation", () => { + it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); const store = createTileLayoutStore(persistence); expect(store.entries).toEqual([tile("a", 0, 2, 2)]); }); + it("loads a v2 document with anchors intact", () => { + const persistence = memoryStore({ + version: 2, + tiles: [{ ...tile("a", 0), col: 2, row: 3 }], + }); + const store = createTileLayoutStore(persistence); + expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]); + }); + it("sanitizes a corrupt persisted document to empty", () => { const persistence = memoryStore(null); // The adapter's load is untyped at the boundary — simulate garbage. @@ -57,11 +66,26 @@ describe("createTileLayoutStore", () => { expect(persistence.saved).toHaveLength(1); // The max order counts KEPT entries (the dropped one doesn't reserve a slot). expect(persistence.saved[0]).toEqual({ - version: 1, + version: 2, tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], }); }); + it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => { + const persistence = memoryStore({ + version: 2, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + ], + }); + const store = createTileLayoutStore(persistence); + + store.sync(["a"]); + + expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]); + }); + it("sync is a no-op (no write) when the merge changes nothing", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); const store = createTileLayoutStore(persistence); @@ -82,20 +106,34 @@ describe("createTileLayoutStore", () => { expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); }); - it("move persists the reordered layout", () => { + it("placeAtCell commits the displacement and pins EVERY tile", () => { const persistence = memoryStore({ version: 1, - tiles: [tile("a", 0), tile("b", 1), tile("c", 2)], + tiles: [tile("a", 0), tile("b", 1)], }); const store = createTileLayoutStore(persistence); - store.move("c", 0); + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // Written as v2. + expect(persistence.saved.at(-1)?.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); - expect(store.entries.map((t) => t.id)).toEqual(["c", "a", "b"]); - expect(store.entries.map((t) => t.order)).toEqual([0, 1, 2]); + expect(persistence.saved).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); }); - it("placeFirstEmpty puts a new id in the first empty slot's fill position", () => { + it("placeFirstEmpty PINS the new id at the first empty slot", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 1, 2), tile("b", 1)], @@ -104,27 +142,33 @@ describe("createTileLayoutStore", () => { store.placeFirstEmpty("new", WIDE); - // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → appended. - expect(store.entries.map((t) => t.id)).toEqual(["a", "b", "new"]); + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); }); it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); const store = createTileLayoutStore(persistence); - store.sync(["a", "x"]); // the refresh appended the new workspace at the end - const before = store.entries; + store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned) store.placeFirstEmpty("x", WIDE); + const pinned = store.entries; + store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable - expect(store.entries).toEqual(before); + expect(store.entries).toEqual(pinned); expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); + // x is PINNED at the first empty slot: a@(1,1) → (1,2). + expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 }); }); - it("sortByRecentActivity re-applies recent-activity order and persists", () => { + it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => { const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0), tile("b", 1)], + version: 2, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ], }); const store = createTileLayoutStore(persistence); @@ -136,5 +180,6 @@ describe("createTileLayoutStore", () => { ); expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); }); }); diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte index be630d2..fb0be0a 100644 --- a/src/features/workspaces/ui/HomeHeader.svelte +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -114,7 +114,7 @@ </button> <ul class="dropdown-content menu z-50 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl"> <li> - <button type="button" onclick={onSort}>Sort by recent activity</button> + <button type="button" onclick={onSort}>Reset arrangement by recent activity</button> </li> </ul> </div> diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts index a0afc52..b4ef6c4 100644 --- a/src/features/workspaces/ui/HomeHeader.test.ts +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -36,7 +36,7 @@ describe("HomeHeader", () => { render(HomeHeader, { props: { ...p, withSort: true } }); await user.click(screen.getByRole("button", { name: "More actions" })); - await user.click(screen.getByRole("button", { name: "Sort by recent activity" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); expect(p.onSort).toHaveBeenCalledTimes(1); }); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte index ceae133..46b3c4e 100644 --- a/src/features/workspaces/ui/HomeTileGrid.svelte +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -1,25 +1,21 @@ <script lang="ts"> import type { WorkspaceEntry } from "@dispatch/wire"; + import { gsap } from "gsap"; + import { Flip } from "gsap/Flip"; + import { tick } from "svelte"; import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; - import type { GridGeometry, Placement, Span } from "../logic/tile-layout"; + import { + displacePlace, + type Cell, + type GridGeometry, + type Placement, + type Span, + } from "../logic/tile-layout"; import { tileTone, type TileTone } from "../logic/view-model"; import type { WorkspaceStore } from "../store.svelte"; import WorkspaceTile from "./WorkspaceTile.svelte"; - /** - * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see - * the literal `bg-*`/`text-*-content` names to emit them). A tile is its - * tone's SATURATED color with the tone's `-content` text, like Win8's - * colored tiles on the dark Start canvas. - */ - const TONE_CLASSES: Record<TileTone, string> = { - primary: "bg-primary text-primary-content border-primary", - secondary: "bg-secondary text-secondary-content border-secondary", - accent: "bg-accent text-accent-content border-accent", - info: "bg-info text-info-content border-info", - success: "bg-success text-success-content border-success", - warning: "bg-warning text-warning-content border-warning", - }; + gsap.registerPlugin(Flip); let { geometry, @@ -32,11 +28,11 @@ hasActive, onOpen, onSelect, - onReorder, + onPlace, onRenameEnd, }: { geometry: GridGeometry; - /** Packed placements (in pack order — the manual tile order). */ + /** Packed placements of the COMMITTED layout (the drag preview derives). */ placements: readonly Placement[]; workspacesById: ReadonlyMap<string, WorkspaceEntry>; /** Forwarded to tiles for the inline rename (semantics preserved). */ @@ -49,8 +45,8 @@ hasActive?: (workspaceId: string) => boolean; onOpen: (id: string) => void; onSelect: (id: string) => void; - /** The drag dropped `id` at `toIndex` in the manual order (-1 = nowhere). */ - onReorder: (id: string, toIndex: number) => void; + /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */ + onPlace: (id: string, cell: Cell | null) => void; onRenameEnd: () => void; } = $props(); @@ -59,6 +55,10 @@ const LONG_PRESS_MS = 500; let phase = $state<DragPhase>({ kind: "idle" }); let longPressTimer: ReturnType<typeof setTimeout> | undefined; + /** Pointer position where the current press began (the follow transform base). */ + let pressOrigin = $state<{ x: number; y: number } | null>(null); + /** The tile mid-settle after a drop (excluded from FLIP until it lands). */ + let settling = $state<string | null>(null); function armLongPress(): void { disarmLongPress(); @@ -76,25 +76,64 @@ } function dispatch(event: DragEvent): void { + const wasDragging = phase.kind === "dragging" ? phase.id : null; const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX }); phase = transition.phase; const emit = transition.emit; if (emit) { if (emit.kind === "tap") onOpen(emit.id); else if (emit.kind === "lift") onSelect(emit.id); - else if (emit.kind === "drop" && emit.targetIndex >= 0) onReorder(emit.id, emit.targetIndex); + else if (emit.kind === "drop") drop(emit.id, emit.targetCell); + } + if (phase.kind === "idle") { + // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above; + // a CANCEL mid-drag flies the tile back to its committed slot. + if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); + pressOrigin = null; } // Keep the long-press armed exactly while a TOUCH press is undecided. if (phase.kind === "pressing" && phase.touch) armLongPress(); else disarmLongPress(); } - /** The hovered tile's index in the manual order (-1 = not over a tile). */ - function hoveredIndex(x: number, y: number): number { - const hit = document.elementFromPoint(x, y)?.closest("[data-tile-id]"); - const id = hit?.getAttribute("data-tile-id"); - if (id === null || id === undefined) return -1; - return placements.findIndex((placement) => placement.id === id); + /** The tile being dragged (or settling after a drop) follows no FLIP. */ + const excludedFromFlip = $derived( + new Set( + [phase.kind === "dragging" ? phase.id : null, settling].filter( + (id): id is string => id !== null, + ), + ), + ); + + /** Commit the drop, then settle the tile from the pointer onto its new cell. */ + function drop(id: string, cell: Cell | null): void { + if (cell !== null) onPlace(id, cell); + flyBack(id); + } + + /** + * Animate the tile's follow transform to identity at its (possibly new) + * committed slot — the drop settle AND the cancel/Escape fly-back. The tile + * is excluded from Flip until the animation completes so the two never + * fight over the same transform. + */ + function flyBack(id: string): void { + const el = tileEls.get(id); + if (!el) return; + settling = id; + void tick().then(() => { + gsap.to(el, { + x: 0, + y: 0, + scale: 1, + duration: 0.2, + ease: "power2.out", + onComplete: () => { + gsap.set(el, { clearProps: "transform" }); + settling = null; + }, + }); + }); } /** Interactive targets inside a tile (the rename input) own their gestures. */ @@ -107,6 +146,7 @@ if (e.button !== 0 || insideInteractive(e.target)) return; // Capture so fast drags keep the pointer events on this element. (e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId); + pressOrigin = { x: e.clientX, y: e.clientY }; dispatch({ type: "pointer-down", id, @@ -122,7 +162,7 @@ type: "pointer-move", x: e.clientX, y: e.clientY, - targetIndex: hoveredIndex(e.clientX, e.clientY), + targetCell: cellAt(e.clientX, e.clientY), }); } @@ -131,9 +171,72 @@ dispatch({ type: "pointer-up" }); } - // ── Tile element registry (for the FLIP reflow animation) ───────────────── + // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the + // selection — the committed layout is untouched and the tile flies home. + function windowKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape" || phase.kind === "idle") return; + e.stopPropagation(); + dispatch({ type: "cancel" }); + } + + // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ───── + let gridEl = $state<HTMLDivElement | undefined>(); + + const pitchX = $derived(geometry.unit.width + geometry.unit.gap); + const pitchY = $derived(geometry.unit.height + geometry.unit.gap); + + function cellAt(clientX: number, clientY: number): Cell | null { + const el = gridEl; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns. + const cellW = wide ? pitchX : rect.width / geometry.flowCols; + const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1); + const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1); + return { + col: wide ? col : Math.min(col, geometry.flowCols), + row: wide ? Math.min(row, geometry.flowRows) : row, + }; + } + + // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─ + /** The dragged tile's id while a drag is in flight (else null). */ + const draggedId = $derived<string | null>( + phase.kind === "dragging" ? phase.id : null, + ); + const draggedPlacement = $derived( + draggedId !== null + ? (placements.find((placement) => placement.id === draggedId) ?? null) + : null, + ); + + /** The pointer's hovered cell while dragging (else null). */ + const hoveredCell = $derived<Cell | null>( + phase.kind === "dragging" ? phase.targetCell : null, + ); + + /** The landing cell: where the pointer is, else the tile's committed slot. */ + const targetCell = $derived<Cell | null>( + hoveredCell ?? + (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null), + ); + + /** + * What to render: while dragging, the displacement of the COMMITTED layout + * at the target cell — recomputed from committed on EVERY hover change (as + * if the user moved straight from the original to here); the FLIP below + * animates from wherever the tiles currently are. Otherwise the committed + * placements. + */ + const rendered = $derived( + draggedPlacement && targetCell && draggedId !== null + ? displacePlace(placements, draggedId, targetCell, geometry) + : placements, + ); + + // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ─── let tileEls = new Map<string, HTMLElement>(); - let lastRects = new Map<string, { x: number; y: number }>(); function trackTile(el: HTMLElement, id: string): { destroy: () => void } { tileEls.set(id, el); @@ -144,32 +247,31 @@ }; } - // FLIP: measure after a placements/geometry change and play the inverted - // delta — reordering edits the ORDER, the reflow is always derived. + let flipState: ReturnType<typeof Flip.getState> | null = null; + + $effect.pre(() => { + // Track the layout inputs; the DOM read below is intentionally untracked. + void rendered + .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`) + .join("|"); + void geometry.mode; + flipState = Flip.getState( + [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el), + ); + }); + $effect(() => { - // Track the layout inputs (the DOM reads below are untracked). - const ids = new Set(placements.map((placement) => placement.id)); + void rendered; void geometry.mode; - const rects = new Map<string, { x: number; y: number }>(); - for (const [id, el] of tileEls) { - if (!ids.has(id)) continue; - const rect = el.getBoundingClientRect(); - rects.set(id, { x: rect.left, y: rect.top }); - } - for (const [id, prev] of lastRects) { - const next = rects.get(id); - const el = tileEls.get(id); - if (!next || !el) continue; - const dx = prev.x - next.x; - const dy = prev.y - next.y; - if ((dx !== 0 || dy !== 0) && typeof el.animate === "function") { - el.animate( - [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], - { duration: 200, easing: "ease-out" }, - ); - } - } - lastRects = rects; + const state = flipState; + flipState = null; + if (!state) return; + Flip.from(state, { + duration: 0.25, + ease: "power2.inOut", + nested: true, + onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }), + }); }); // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ──── @@ -209,15 +311,34 @@ } // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── - const gridStyle = $derived( - geometry.mode === "wide" - ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + + const dragging = $derived(phase.kind === "dragging"); + + const gridStyle = $derived.by(() => { + const base = + geometry.mode === "wide" + ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + ` gap: ${geometry.unit.gap}px; width: max-content;` - : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + + : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + - ` gap: ${geometry.unit.gap}px;`, - ); + ` gap: ${geometry.unit.gap}px;`; + if (!dragging) return base; + // The drop-target dots: one faint dot at each unit cell's CENTER, painted + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. + const dotSize = + geometry.mode === "wide" ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; + const dotX = geometry.mode === "wide" ? geometry.unit.width / 2 : 0; + const dot = + ` background-image: radial-gradient(circle 2.5px,` + + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + + ` background-size: ${dotSize} ${pitchY}px;` + + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (geometry.mode === "wide" + ? ` padding-right: ${pitchX}px;` + : ` padding-bottom: ${pitchY}px;`); + return base + dot; + }); function spanFor(placement: Placement): Span { if (preview && preview.id === placement.id) { @@ -230,8 +351,48 @@ const span = spanFor(placement); return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; } + + /** The dragged tile's follow transform (it keeps its committed slot). */ + function followStyle(placement: Placement): string { + if (draggedId !== placement.id || !pressOrigin || phase.kind !== "dragging") return ""; + const dx = phase.x - pressOrigin.x; + const dy = phase.y - pressOrigin.y; + return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; + } + + // ── The drop ghost: a dashed footprint at the target anchor ─────────────── + const ghostStyle = $derived.by(() => { + if (!dragging || !targetCell || !draggedPlacement) return null; + const span = spanFor(draggedPlacement); + const wide = geometry.mode === "wide"; + const h = span.rowSpan * pitchY - geometry.unit.gap; + const x = wide + ? `${(targetCell.col - 1) * pitchX}px` + : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`; + const width = wide + ? `${span.colSpan * pitchX - geometry.unit.gap}px` + : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`; + return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`; + }); + + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record<TileTone, string> = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; </script> +<svelte:window onkeydowncapture={windowKeydown} /> + <div class="relative min-h-0 flex-1"> <div bind:this={scrollerEl} @@ -242,21 +403,40 @@ onscroll={updateScroll} onwheel={handleWheel} > - <div class="grid" style={gridStyle}> - {#each placements as placement (placement.id)} + <div bind:this={gridEl} class="relative grid" style={gridStyle}> + {#if ghostStyle && draggedPlacement} + <!-- The landing footprint (the dragged tile's tone, dashed). --> + <div + aria-hidden="true" + class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70" + class:border-primary={tileTone(draggedPlacement.id) === "primary"} + class:border-secondary={tileTone(draggedPlacement.id) === "secondary"} + class:border-accent={tileTone(draggedPlacement.id) === "accent"} + class:border-info={tileTone(draggedPlacement.id) === "info"} + class:border-success={tileTone(draggedPlacement.id) === "success"} + class:border-warning={tileTone(draggedPlacement.id) === "warning"} + style={ghostStyle} + ></div> + {/if} + {#each rendered as placement (placement.id)} {@const ws = workspacesById.get(placement.id)} {#if ws} + {@const isDragged = draggedId === placement.id} <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> <div data-tile-id={placement.id} - class={`flex select-none flex-col overflow-hidden rounded-box border shadow-md transition-[transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:shadow-xl ${ + class={`flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${ TONE_CLASSES[tileTone(placement.id)] } ${ selectedId === placement.id ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100" : "border-transparent" - } ${phase.kind === "dragging" && phase.id === placement.id ? "opacity-40" : ""}`} - style={`${wrapperStyle(placement)} scroll-snap-align: start;`} + } ${ + isDragged + ? "pointer-events-none relative z-40 shadow-2xl" + : "shadow-md hover:-translate-y-0.5 hover:shadow-xl" + }`} + style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`} role="button" tabindex="0" aria-label={`Open workspace ${ws.title}`} @@ -316,13 +496,3 @@ </div> {/if} </div> - -{#if phase.kind === "dragging"} - <!-- The drag ghost follows the pointer (transient; never a data change). --> - <div - class="pointer-events-none fixed z-50 rounded-box border border-base-content/25 bg-base-100/95 px-3 py-2 text-sm font-bold shadow-2xl" - style={`left: ${phase.x + 10}px; top: ${phase.y + 10}px;`} - > - {workspacesById.get(phase.id)?.title ?? phase.id} - </div> -{/if} diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts index 3229d4e..d70c673 100644 --- a/src/features/workspaces/ui/HomeTileGrid.test.ts +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -41,7 +41,7 @@ function baseProps() { store: fakeStore(), onOpen: vi.fn(), onSelect: vi.fn(), - onReorder: vi.fn(), + onPlace: vi.fn(), onRenameEnd: vi.fn(), }; } @@ -98,32 +98,51 @@ describe("HomeTileGrid", () => { expect(props.onOpen).not.toHaveBeenCalled(); }); - it("dragging shows the transient ghost, and dropping reorders (when over a target)", async () => { + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { const props = baseProps(); const { container } = render(HomeTileGrid, { props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, }); const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); - // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op), - // but the GHOST must appear while dragging and disappear on drop. - Object.defineProperty(document, "elementFromPoint", { - configurable: true, - value: () => null, - }); + // The cell math resolves against the grid element's rect (jsdom: 0s). fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); - expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull(); + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); fireEvent.pointerUp(wrapper); - expect(container.querySelector(".pointer-events-none.fixed")).toBeNull(); - // No hit target → no reorder call. - expect(props.onReorder).not.toHaveBeenCalled(); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); // And no navigation either (a drag is not a tap). expect(props.onOpen).not.toHaveBeenCalled(); }); + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + it("a touch long-press lifts the tile and selects it (never opens)", async () => { const props = baseProps(); render(HomeTileGrid, { diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index cbf762f..7c0db85 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -276,7 +276,7 @@ {...(hasActive ? { hasActive } : {})} onOpen={openWorkspace} onSelect={select} - onReorder={(id, toIndex) => tiles.move(id, toIndex)} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} onRenameEnd={() => (renamingId = null)} /> {/if} |
