Count clicks ourselves so double-clicks work at all
Pixi only fills `detail` on the `click` event it synthesises; on `pointerdown` it is always 0, so every `e.detail === 2` branch was dead code. Asset rows, hierarchy folding and select-all in text fields never responded to a double-click. A shared click counter in src/ui/clicks.ts replaces the three readings of `e.detail`. A finished double-click ends the run, so two in a row count 2 and 2 rather than 3 and 4. The harness could only send `clickCount: 1`, which is why no test caught this: every "activate" check went through Enter. It can send real double-clicks now, and five checks drive the three paths separately.
This commit is contained in:
1 parent
d7c8d3e579
commit
ed1fe31c77
11 files changed
+196
-5
No files matched your search
@@ -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 `<input>` 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
|
||||
```
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user