The Parametric EQ has a tab of its own since #370, but Play is in the sound FX's tab. A tab that can float keeps both in view. There is one editor per kind, so the kind's pane is lent to the window and given back on Dock, the way fullscreen moves the game view. - A right-click on a document's tab opens a menu with Float and Close. `DocumentTab` passes the press to `TabStrip`'s new `onMenu`. - `FloatWindow` sits on the overlay. Its title bar moves it, its lower right corner sizes it, Dock and the x close it. A press brings it to the front. - `floating.ts` is the pure part: where a window opens, held whole on the screen, sized down to 320 x 200. Nothing of it is remembered. - One window per kind. Another document of that kind opens there and closes the one it had. Its tab, if it had one, leaves the row. - When the row showed the kind, it shows the nearest document whose kind is not in a window, else the scene (`neighbourOf`). - Delete, Copy and Paste go to the window pressed last, and back to the tab showing after a press in the editor (`keyTab`). - Previews tick and redraw for a kind in a window as for the tab showing. Fullscreen hides the windows. Loading a project or signing out closes them. - Only documents float, Scene and Game stay in the row. - Hooks: `floatWindows`, `floatDocument`, `keyTarget`. Tests: - Unit documents: 16 green, 5 new. Counter-check: `neighbourOf` without the window check turns its test red. - e2e 44 (melektaus-atx, after steps 1-12 and 37): ALL PASSED, 505 checks in the chain, 6 new for float, move, size, another texture, keys, Dock and x. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4236 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
import { clickDocumentTab } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Tabs as open documents (#171): a double-click in the asset panel opens a tab per asset, several of
|
||
* one kind at once; the tab keeps its document whatever is selected, and its zoom when another tab
|
||
* was on screen; the close cross and a middle click close it, a rename renames it and a deleted
|
||
* asset takes its tab along.
|
||
*/
|
||
export async function documents(t) {
|
||
const { check, json, evaluate, mutate, click, doubleClick, wheel, shot, showTab } = t;
|
||
await showTab('scene');
|
||
for (const doc of (await json('__gm.documents()')).docs)
|
||
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
|
||
// Short names of their own: an earlier step leaves them too long for a tab to show whole.
|
||
const [a, b] = await json(`(() => {
|
||
const two = __gm.state.project.assets.filter((x) => x.type === 'texture' && x.width > 0).slice(0, 2);
|
||
const out = two.map((x, i) => ({ id: x.id, name: 'Sheet ' + 'AB'[i], was: x.name }));
|
||
two.forEach((x, i) => (x.name = out[i].name));
|
||
__gm.state.preview();
|
||
return out;
|
||
})()`);
|
||
const behavior = await json(
|
||
`(() => { const x = __gm.state.project.assets.find((x) => x.type === 'behavior' && x.graph.nodes.length > 0); return { id: x.id, node: x.graph.nodes[0].id }; })()`,
|
||
);
|
||
const docs = () => json('__gm.documents()');
|
||
const current = () => json('__gm.texture.current()');
|
||
const place = async (doc) => (await json('__gm.tabRow()')).shown.find((p) => p.tab === doc);
|
||
|
||
// 44a. Two textures, two tabs; each names its kind in front of the name.
|
||
for (const texture of [a, b]) {
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
|
||
await doubleClick(row.x, row.y);
|
||
}
|
||
const opened = await docs();
|
||
check(
|
||
'double-clicking two textures opens a tab for each, the second one showing',
|
||
JSON.stringify(opened.docs) === JSON.stringify([`asset:${a.id}`, `asset:${b.id}`]) &&
|
||
opened.active === `asset:${b.id}` &&
|
||
(await current()) === b.id,
|
||
JSON.stringify({ opened, current: await current() }),
|
||
);
|
||
const placeA = await place(`asset:${a.id}`);
|
||
const inTab = (p) => p.x > placeA.x && p.x < placeA.right && p.y > placeA.y && p.y < placeA.y + placeA.h;
|
||
const nameHits = await json(`__gm.locateAll(${JSON.stringify(a.name)})`);
|
||
const kindWords = (await json(`__gm.locateAll('Texture')`)).filter(inTab);
|
||
// The icon's 16 px in front of the name, read against the tab's own background at its left edge.
|
||
const iconInk = await json(`(() => {
|
||
const x0 = ${placeA.x} + 6, y = ${placeA.y + placeA.h / 2};
|
||
const grid = [];
|
||
for (let dy = -7; dy <= 7; dy++) for (let dx = 0; dx < 16; dx++) grid.push({ x: x0 + dx, y: y + dy });
|
||
const [background, ...ink] = __gm.pixels([{ x: ${placeA.x} + 1, y }, ...grid]);
|
||
return ink.filter((p) => p !== background).length;
|
||
})()`);
|
||
check(
|
||
'a tab shows the icon of its kind, then the asset’s name – the kind no longer as a word',
|
||
placeA?.kind === 'Texture' && nameHits.some(inTab) && kindWords.length === 0 && iconInk >= 20,
|
||
JSON.stringify({ placeA, nameHits, kindWords, iconInk }),
|
||
);
|
||
|
||
// 44b. Selecting the other texture is a look at the inspector, not a change of document.
|
||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(a.id)} })`);
|
||
check('selecting the other texture leaves the tab on its own', (await current()) === b.id, await current());
|
||
|
||
// 44c. Zoom and scroll stay with the document.
|
||
const view = () =>
|
||
json(
|
||
`(() => { const o = __gm.texture.pointAt(0, 0); const p = __gm.texture.pointAt(10, 0); return { x: o.x, y: o.y, scale: (p.x - o.x) / 10 }; })()`,
|
||
);
|
||
const fitted = await view();
|
||
const rect = await json('__gm.texture.rect()');
|
||
await wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, -300);
|
||
const zoomed = await view();
|
||
check('the wheel zooms the texture', zoomed.scale > fitted.scale * 1.2, JSON.stringify({ fitted, zoomed }));
|
||
await clickDocumentTab(t, `asset:${a.id}`);
|
||
check('its neighbour’s tab shows the other texture', (await current()) === a.id, await current());
|
||
await clickDocumentTab(t, `asset:${b.id}`);
|
||
const back = await view();
|
||
check(
|
||
'coming back, the texture is zoomed and scrolled as it was left',
|
||
(await current()) === b.id &&
|
||
Math.abs(back.scale - zoomed.scale) < 1e-6 &&
|
||
Math.abs(back.x - zoomed.x) < 0.5 &&
|
||
Math.abs(back.y - zoomed.y) < 0.5,
|
||
JSON.stringify({ zoomed, back }),
|
||
);
|
||
|
||
// 44d. A rename renames the tab.
|
||
await mutate(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = 'Renamed sheet'; __gm.state.preview();`,
|
||
);
|
||
const renamedAt = await place(`asset:${b.id}`);
|
||
const renamedHits = await json(`__gm.locateAll('Renamed sheet')`);
|
||
check(
|
||
'renaming the asset renames its tab',
|
||
renamedHits.some(
|
||
(p) =>
|
||
p.x > renamedAt.x && p.x < renamedAt.right && p.y > renamedAt.y && p.y < renamedAt.y + renamedAt.h,
|
||
),
|
||
JSON.stringify({ renamedAt, renamedHits }),
|
||
);
|
||
await mutate(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = ${JSON.stringify(b.name)}; __gm.state.preview();`,
|
||
);
|
||
|
||
// 44e. A node lives only in its graph: selecting one brings that graph up, in a tab of its own.
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(behavior.id)}, nodeId: ${JSON.stringify(behavior.node)} })`,
|
||
);
|
||
const withGraph = await docs();
|
||
check(
|
||
'selecting a node opens its behavior next to the texture showing',
|
||
withGraph.active === `asset:${behavior.id}` &&
|
||
JSON.stringify(withGraph.docs) ===
|
||
JSON.stringify([`asset:${a.id}`, `asset:${b.id}`, `asset:${behavior.id}`]) &&
|
||
(await json('__gm.tab()')) === 'graph',
|
||
JSON.stringify(withGraph),
|
||
);
|
||
await shot('44-documents');
|
||
|
||
// 44f. The close cross closes the tab on screen; its left neighbour comes up.
|
||
const graphTab = await place(`asset:${behavior.id}`);
|
||
await click(graphTab.close.x, graphTab.close.y);
|
||
const afterClose = await docs();
|
||
check(
|
||
'the close cross closes the tab, and the one left of it shows',
|
||
!afterClose.docs.includes(`asset:${behavior.id}`) &&
|
||
afterClose.active === `asset:${b.id}` &&
|
||
(await current()) === b.id,
|
||
JSON.stringify(afterClose),
|
||
);
|
||
|
||
// 44g. A middle click closes a tab that is not showing, and leaves the one that is.
|
||
const tabA = await place(`asset:${a.id}`);
|
||
await click((tabA.x + tabA.close.x) / 2, tabA.y + tabA.h / 2, 'middle');
|
||
const afterMiddle = await docs();
|
||
check(
|
||
'a middle click closes a tab in the background',
|
||
JSON.stringify(afterMiddle.docs) === JSON.stringify([`asset:${b.id}`]) &&
|
||
afterMiddle.active === `asset:${b.id}`,
|
||
JSON.stringify(afterMiddle),
|
||
);
|
||
|
||
// 44h. A deleted asset takes its tab along, and the last one gone leaves the Scene tab showing.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
window.__deletedSheet = p.assets.find((x) => x.id === ${JSON.stringify(b.id)});
|
||
p.assets = p.assets.filter((x) => x.id !== ${JSON.stringify(b.id)});
|
||
__gm.state.preview();
|
||
`);
|
||
const afterDelete = await docs();
|
||
check(
|
||
'deleting the asset closes its tab and goes back to the scene',
|
||
afterDelete.docs.length === 0 && (await json('__gm.tab()')) === 'scene',
|
||
JSON.stringify({ afterDelete, tab: await json('__gm.tab()') }),
|
||
);
|
||
await mutate(
|
||
`__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`,
|
||
);
|
||
|
||
// 44i. A right-click on a tab floats it: the document leaves the row for a window of its own.
|
||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${a.id}`)})`);
|
||
const tabOfA = await place(`asset:${a.id}`);
|
||
await click(tabOfA.x + 20, tabOfA.y + tabOfA.h / 2, 'right');
|
||
const floatEntry = (await json(`__gm.locateAll('Float')`)).find(
|
||
(p) => p.y > tabOfA.y + tabOfA.h && Math.abs(p.x - tabOfA.x) < 200,
|
||
);
|
||
if (floatEntry) await click(floatEntry.x, floatEntry.y);
|
||
const windows = () => json('__gm.floatWindows()');
|
||
const floated = await windows();
|
||
const inWindow = await json('__gm.texture.rect()');
|
||
const box = floated[0]?.box;
|
||
check(
|
||
'Float in a tab’s menu moves the document into a window, the texture in it',
|
||
!(await docs()).docs.includes(`asset:${a.id}`) &&
|
||
floated.length === 1 &&
|
||
floated[0].kind === 'texture' &&
|
||
floated[0].doc === `asset:${a.id}` &&
|
||
floated[0].title === a.name &&
|
||
(await json('__gm.tab()')) === 'scene' &&
|
||
(await current()) === a.id &&
|
||
inWindow.x >= box.x &&
|
||
inWindow.x + inWindow.w <= box.x + box.w &&
|
||
inWindow.y > box.y &&
|
||
inWindow.y + inWindow.h <= box.y + box.h,
|
||
JSON.stringify({ floatEntry, floated, inWindow, docs: await docs() }),
|
||
);
|
||
|
||
// 44j. The title bar moves the window, its corner sizes it, and the texture grows along.
|
||
await t.drag(floated[0].bar.x, floated[0].bar.y, floated[0].bar.x - 100, floated[0].bar.y - 60);
|
||
const moved = (await windows())[0];
|
||
await t.drag(moved.grip.x, moved.grip.y, moved.grip.x + 80, moved.grip.y + 40);
|
||
const grown = (await windows())[0];
|
||
const grownRect = await json('__gm.texture.rect()');
|
||
check(
|
||
'dragging the title bar moves the window, dragging its corner sizes it',
|
||
moved.box.x === box.x - 100 &&
|
||
moved.box.y === box.y - 60 &&
|
||
grown.box.w === box.w + 80 &&
|
||
grown.box.h === box.h + 40 &&
|
||
grown.box.x === moved.box.x &&
|
||
grownRect.w === inWindow.w + 80,
|
||
JSON.stringify({ box, moved: moved.box, grown: grown.box, inWindow, grownRect }),
|
||
);
|
||
await shot('44-float');
|
||
|
||
// 44k. Another texture opened goes into the window: the kind has one editor, so one window.
|
||
const rowB = await json(`__gm.assetRow(${JSON.stringify(b.id)})`);
|
||
await doubleClick(rowB.x, rowB.y);
|
||
const other = await windows();
|
||
check(
|
||
'opening another texture shows it in the window, not in a tab',
|
||
other.length === 1 &&
|
||
other[0].doc === `asset:${b.id}` &&
|
||
other[0].title === b.name &&
|
||
!(await docs()).docs.includes(`asset:${b.id}`) &&
|
||
(await current()) === b.id,
|
||
JSON.stringify({ other, docs: await docs() }),
|
||
);
|
||
|
||
// 44l. Delete, Copy and Paste go where the last press went.
|
||
await click(other[0].bar.x, other[0].bar.y);
|
||
const toWindow = await json('__gm.keyTarget()');
|
||
const scene = (await json('__gm.tabRow()')).shown.find((p) => p.tab === 'scene');
|
||
await click((scene.x + scene.right) / 2, scene.y + scene.h / 2);
|
||
const toTabs = await json('__gm.keyTarget()');
|
||
check(
|
||
'keys go to the window after a press in it, and to the tab after a press in the editor',
|
||
toWindow === 'texture' && toTabs === 'scene',
|
||
JSON.stringify({ toWindow, toTabs }),
|
||
);
|
||
|
||
// 44m. Dock puts the document back into the row, showing, with its pane below the row.
|
||
await click(other[0].dock.x, other[0].dock.y);
|
||
const docked = await docs();
|
||
const dockedRect = await json('__gm.texture.rect()');
|
||
check(
|
||
'Dock brings the document back as a tab, showing',
|
||
(await windows()).length === 0 &&
|
||
docked.active === `asset:${b.id}` &&
|
||
(await json('__gm.tab()')) === 'texture' &&
|
||
(await current()) === b.id &&
|
||
dockedRect.w > 0 &&
|
||
dockedRect.h > 0,
|
||
JSON.stringify({ docked, dockedRect }),
|
||
);
|
||
|
||
// 44n. The window's × closes the document, and the kind shows in the tabs again.
|
||
await evaluate(`__gm.floatDocument(${JSON.stringify(`asset:${b.id}`)})`);
|
||
const again = await windows();
|
||
await click(again[0].close.x, again[0].close.y);
|
||
const closed = { windows: await windows(), docs: await docs() };
|
||
await showTab('texture');
|
||
check(
|
||
'the × closes the window and its document; the kind opens in a tab again',
|
||
closed.windows.length === 0 &&
|
||
!closed.docs.docs.includes(`asset:${b.id}`) &&
|
||
(await json('__gm.tab()')) === 'texture' &&
|
||
(await json('__gm.texture.rect()')).w > 0,
|
||
JSON.stringify({ again, closed, tab: await json('__gm.tab()') }),
|
||
);
|
||
for (const doc of (await docs()).docs) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
await showTab('scene');
|
||
|
||
await mutate(`
|
||
for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was;
|
||
__gm.state.preview();
|
||
`);
|
||
}
|