diff --git a/README.md b/README.md index 3815b35e..2afd5961 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,10 @@ selected and switched back on. - **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields, popups. The only DOM element is a hidden `` used as a keyboard bridge (IME, mobile keyboard, clipboard) while a canvas text field is focused. + Double-clicks are counted in `clicks.ts`, not read off the event: Pixi only puts a click count + on the `click` it synthesises and leaves `detail` at 0 on `pointerdown`, so every press handler + that trusted `e.detail` never saw a second click. Presses are counted per key rather than per + widget, because a list rebuilds its rows when the first click changes the selection. Menus read as lists: entries are left-aligned and scroll once they pass 320 px instead of running off the screen. A menu that grows with the project – the parent picker lists every object of the scene – is opened with `{ searchable: true }`, which puts a filter box above @@ -429,7 +433,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # where the browser runs; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 293 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 299 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end ``` diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a08b549e..7331063b 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -181,6 +181,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => hierarchy.rowOrder(), hierarchyChevron: (id: string) => hierarchy.chevronPosition(id), + /** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */ + hierarchyRow: (id: string) => hierarchy.rowPosition(id), hierarchyRowColor: (id: string) => hierarchy.rowColor(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 5e8bd19f..3a1dd6bb 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -142,6 +142,11 @@ export class HierarchyPanel extends Panel { return this.list.chevronGlobal(id); } + /** Global centre of a row, away from its chevron (test hook). */ + rowPosition(id: string): { x: number; y: number } | null { + return this.list.rowGlobal(id); + } + expandAll(): void { for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id); this.refresh(); diff --git a/src/ui/clicks.ts b/src/ui/clicks.ts new file mode 100644 index 00000000..44031f3a --- /dev/null +++ b/src/ui/clicks.ts @@ -0,0 +1,48 @@ +import type { FederatedPointerEvent } from 'pixi.js'; + +/** Longest gap between two presses that still reads as one double-click. */ +const DOUBLE_MS = 400; +/** How far the pointer may travel between them. */ +const SLOP = 6; + +interface LastPress { + key: unknown; + button: number; + time: number; + x: number; + y: number; + count: number; +} + +let last: LastPress = { key: null, button: -1, time: 0, x: 0, y: 0, count: 0 }; + +/** + * How often `key` was pressed in a row with the same button – 2 on a double-click. Pixi counts + * clicks only for the `click`/`pointertap` it synthesises and leaves `detail` at 0 on + * `pointerdown`, so a press handler reading `e.detail` never sees a second click. + * + * `key` identifies what was hit and should survive a rebuild of the widget it belongs to: list + * rows are thrown away and recreated whenever the list refreshes, which a selection – the first + * click – can trigger. + */ +export function pressCount(e: FederatedPointerEvent, key: unknown): number { + const gap = e.timeStamp - last.time; + const repeat = + key === last.key && + e.button === last.button && + gap >= 0 && + gap <= DOUBLE_MS && + Math.hypot(e.global.x - last.x, e.global.y - last.y) <= SLOP; + const count = repeat ? last.count + 1 : 1; + last = { + key, + button: e.button, + time: e.timeStamp, + x: e.global.x, + y: e.global.y, + // A finished double-click ends the run: folding a row open and shut again is two + // double-clicks, and the second must count as one rather than as clicks three and four. + count: count === 2 ? 0 : count, + }; + return count; +} diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 1fec7217..19543809 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -1,4 +1,5 @@ import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { pressCount } from '../clicks'; import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; @@ -223,7 +224,7 @@ export class ListView extends Widget { // being dragged into. if (!this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId)) this.select(item.id); - if (e.detail === 2) this.onActivate?.(item.id); + if (pressCount(e, item.id) === 2) this.onActivate?.(item.id); else if (this.onDrop || this.onDragOut) { this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null }; } diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index ca4a6b12..07a9c6db 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -1,4 +1,5 @@ import { CanvasTextMetrics, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { pressCount } from '../clicks'; import { drawControlBox } from '../draw'; import type { TextEditTarget } from '../KeyboardBridge'; import { centreY, clamp } from '../math'; @@ -101,11 +102,12 @@ export class TextField extends Widget implements TextEditTarget { if (e.button !== 0) return; e.stopPropagation(); const index = this.indexAt(this.toLocal(e.global).x); + const presses = pressCount(e, this); if (!this.editing) { ui().focus.focus(this); - if (e.detail >= 2) this.selectAll(); + if (presses >= 2) this.selectAll(); else this.setSelection(index, index, true); - } else if (e.detail >= 2) { + } else if (presses >= 2) { this.selectAll(); } else { this.setSelection(index, index, true); diff --git a/tests/ui/clicks.test.ts b/tests/ui/clicks.test.ts new file mode 100644 index 00000000..152e6e6f --- /dev/null +++ b/tests/ui/clicks.test.ts @@ -0,0 +1,53 @@ +import type { FederatedPointerEvent } from 'pixi.js'; +import { describe, expect, it } from 'vitest'; +import { pressCount } from '../../src/ui/clicks'; + +interface Press { + time: number; + x?: number; + y?: number; + button?: number; +} + +const press = (p: Press): FederatedPointerEvent => + ({ + timeStamp: p.time, + button: p.button ?? 0, + global: { x: p.x ?? 100, y: p.y ?? 100 }, + }) as unknown as FederatedPointerEvent; + +/** Counts a run of presses on one key. Each test uses its own key, so no run reaches the next. */ +const counts = (key: unknown, presses: Press[]): number[] => presses.map((p) => pressCount(press(p), key)); + +describe('pressCount', () => { + it('counts a second press on the same target as a double-click', () => { + expect(counts('row-a', [{ time: 0 }, { time: 120 }])).toEqual([1, 2]); + }); + + it('starts over when the presses are too far apart in time', () => { + expect(counts('row-b', [{ time: 0 }, { time: 900 }])).toEqual([1, 1]); + }); + + it('starts over when the pointer moved to another row', () => { + expect(counts('row-c', [{ time: 0 }, { time: 100, y: 140 }])).toEqual([1, 1]); + }); + + it('tolerates the wobble of a hand holding the mouse still', () => { + expect(counts('row-d', [{ time: 0 }, { time: 100, x: 103, y: 102 }])).toEqual([1, 2]); + }); + + it('does not join presses on different targets', () => { + const first = pressCount(press({ time: 0 }), 'row-e'); + const second = pressCount(press({ time: 100 }), 'row-f'); + expect([first, second]).toEqual([1, 1]); + }); + + it('does not join presses of different buttons', () => { + expect(counts('row-g', [{ time: 0 }, { time: 100, button: 2 }])).toEqual([1, 1]); + }); + + it('starts a new run after a double-click, so a second pair counts again', () => { + // Folding a row open and shut again is two double-clicks in a row, well inside the window. + expect(counts('row-h', [{ time: 0 }, { time: 100 }, { time: 200 }, { time: 300 }])).toEqual([1, 2, 1, 2]); + }); +}); diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index 127f4d06..54d504c7 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -6,7 +6,7 @@ import { same } from '../lib/harness.mjs'; * without touching the selection, and selecting a nested object unfolds the way down to it. */ export async function hierarchy(t) { - const { check, sleep, evaluate, json, click, select, state, shot } = t; + const { check, sleep, evaluate, json, click, doubleClick, select, state, shot } = t; const rowIds = () => json('__gm.hierarchyOrder()'); const rows = async () => JSON.stringify(await rowIds()); const selectionBefore = (await state()).selection; @@ -31,6 +31,22 @@ export async function hierarchy(t) { same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows(), ); + // The row itself folds on a double-click, the same as its chevron. Pixi leaves `detail` at 0 on + // a press, so this needs a real double-click from the harness, not two `click()` calls. + const row = await json(`__gm.hierarchyRow('o2')`); + await doubleClick(row.x, row.y); + check( + 'double-clicking the row unfolds it as well', + same(await rowIds(), ['o1', 'o2', 'oc2', 'o3', 'o5']), + await rows(), + ); + await doubleClick(row.x, row.y); + check( + 'double-clicking it again folds the subtree away', + same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + await rows(), + ); + await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' }); await sleep(150); check( diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 9f958c73..de8b3d34 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -10,6 +10,7 @@ export async function inspector(t) { locate, shot, click, + doubleClick, clickText, drag, press, @@ -71,6 +72,27 @@ export async function inspector(t) { ); await shot('04-renamed'); + // 2b. A double-click into a text field selects its content, so the next keystroke replaces the + // whole name instead of inserting into it. Renamed back, since later checks go by the name. + await doubleClick(nameLabel.x + 160, nameLabel.y); + await typeText('Twice'); + await press('Enter'); + await sleep(200); + check( + 'double-clicking a text field selects all – typing replaces the name', + (await object('o2')).name === 'Twice', + JSON.stringify((await object('o2')).name), + ); + // Undone rather than typed back: check 6 further down counts its undos, so this must not + // leave a step on the stack. + await undo(); + await sleep(200); + check( + 'undo takes that rename back', + (await object('o2')).name === 'Renamed crate', + JSON.stringify((await object('o2')).name), + ); + // 3. Scrub the X field (right of the "X" label) by 100 px. const xLabel = await locate('X'); await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index cb61a120..dbab9e63 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -7,8 +7,10 @@ export async function assets(t) { json, shot, click, + doubleClick, clickText, mouse, + mutate, press, redo, undo, @@ -79,6 +81,28 @@ export async function assets(t) { check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); await shot('12-new-asset'); + // 9b. Double-click on a scene row: the one thing activating an asset still does since placing + // became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry + // shares its label with the Scene tab, so a click by text could land on either. + await mutate( + `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`, + ); + const secondScene = await json("__gm.assetRow('scene2')"); + await doubleClick(secondScene.x, secondScene.y); + check( + 'double-clicking a scene asset makes it the active scene', + (await json('__gm.state.project.activeSceneId')) === 'scene2', + await json('__gm.state.project.activeSceneId'), + ); + const firstScene = await json("__gm.assetRow('scene1')"); + await doubleClick(firstScene.x, firstScene.y); + check( + 'double-clicking the first one switches back', + (await json('__gm.state.project.activeSceneId')) === 'scene1', + await json('__gm.state.project.activeSceneId'), + ); + await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`); + // 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour, // so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's // reset is the first on screen and colour's the last. diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 1aec0e8e..9de17b31 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -157,6 +157,19 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await mouse('mouseReleased', x, y, button, extra); await sleep(150); }; + /** + * A real double-click: two press/release pairs in quick succession, the second carrying + * `clickCount: 2` the way a browser reports one. `click()` twice would not do – the editor + * only counts presses that arrive close enough in time. + */ + const doubleClick = async (x, y) => { + await mouse('mouseMoved', x, y); + for (const clickCount of [1, 2]) { + await mouse('mousePressed', x, y, 'left', { clickCount }); + await mouse('mouseReleased', x, y, 'left', { clickCount }); + } + await sleep(150); + }; const clickText = async (text, button = 'left') => { const p = await locate(text); await click(p.x, p.y, button); @@ -316,6 +329,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { shot, mouse, click, + doubleClick, clickText, drag, dragAsset,