diff --git a/README.md b/README.md index 2d6922ae..4bc76e23 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1739 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1740 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 51c02f47..3e152d5a 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -1,6 +1,7 @@ import type { AssetType } from '../../core/model/AssetTypes'; import { Button, + Grip, Label, Panel, SectionHeader, @@ -169,7 +170,7 @@ export function uiHooks({ app, state, editor }: HookContext) { /** The mark in front of the row named `label` – what a variable, a field or a port is dragged by (#310). */ inspectorMark: (label: string, last = false) => { const mark = besideOf(label, last, -1); - return mark && centreOf(mark); + return mark && { ...centreOf(mark), dots: mark instanceof Grip ? mark.dotsShown : 'none' }; }, /** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */ inspectorEyedropper: (label: string, last = false) => { diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index aeac62df..d9618d5c 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -37,6 +37,7 @@ import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core import { Checkbox, Dropdown, + Grip, HStack, Label, Menu, @@ -295,7 +296,7 @@ function portRow( ): SortBlock { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; - const mark = markLabel(port.kind); + const mark = new Grip(markLabel(port.kind)); row.add(mark); row.add(name, 1); for (const extra of extras) row.add(extra); diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 02c27728..2b193484 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -4,6 +4,7 @@ import type { PropertyDef, StructFieldDef } from '../../core/properties/Property import { Button, Dropdown, + Grip, HStack, Icon, Label, @@ -88,7 +89,7 @@ export interface VariableOptions { /** A variable's first row, and its mark – where the asset lets it be dragged to another place (#310). */ export interface VariableRow extends FieldRow { - grip: Widget; + grip: Grip; } /** @@ -104,7 +105,7 @@ export function buildVariable( ): VariableRow { const { edit, typing, keep, hidden, actions } = options; const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); - const grip = markLabel(def.kind); + const grip = new Grip(markLabel(def.kind)); const head = gridRow(0, grip, name); let setValue: (next: PropertyValue) => void = () => {}; let below: Widget | undefined; @@ -258,10 +259,10 @@ function fieldRow( field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined, -): { row: HStack; mark: Widget } { +): { row: HStack; mark: Grip } { const depth = path.length + 1; const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next))); - const mark = markLabel(field.kind); + const mark = new Grip(markLabel(field.kind)); return { row: gridRow(depth, mark, name), mark }; } diff --git a/src/editor/list/InlineList.ts b/src/editor/list/InlineList.ts index 29f1bb32..471a8159 100644 --- a/src/editor/list/InlineList.ts +++ b/src/editor/list/InlineList.ts @@ -12,6 +12,7 @@ import { Button, Dropdown, + Grip, HStack, Label, Menu, @@ -354,7 +355,7 @@ export class InlineList extends VStack { const label = new Label(String(index), { style: 'small', align: 'right' }); label.minWidth = INDEX_WIDTH; if (tall) label.minHeight = Theme.rowHeight; - row.add(label); + const grip = row.add(new Grip(label)); // A typed list's items are its type: there is no other kind to turn one into. const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind); if (tall) mark.minHeight = Theme.rowHeight; @@ -378,10 +379,10 @@ export class InlineList extends VStack { const widgets = this.rowWidgets[index]; if (widgets) widgets.remove = remove; this.add(row); - if (!below) return { grip: label, rows: [row] }; + if (!below) return { grip, rows: [row] }; const items = indented(below, INDEX_WIDTH + Theme.gap); this.add(items); - return { grip: label, rows: [row, items] }; + return { grip, rows: [row, items] }; } /** A list inside this one: its head goes where a value would, its items below, one step further in. */ diff --git a/src/ui/blockDrag.ts b/src/ui/blockDrag.ts index 68db99b5..b6122aad 100644 --- a/src/ui/blockDrag.ts +++ b/src/ui/blockDrag.ts @@ -1,13 +1,14 @@ import { type Container, type FederatedPointerEvent, Graphics, Point } from 'pixi.js'; -import { cancelFrameWork, onFrame } from './render'; +import { cancelFrameWork, onFrame, requestRender } from './render'; import { blockGap, type BlockSpan, dropSlot, edgeScroll, gapLineY } from './sortable'; import { Theme } from './Theme'; import type { Widget } from './Widget'; +import type { Grip } from './widgets/Grip'; import { ScrollContainer } from './widgets/ScrollContainer'; /** One thing to reorder: where it is taken hold of, and the rows it fills in its column. */ export interface SortBlock { - grip: Widget; + grip: Grip; /** Siblings in one column, top to bottom. */ rows: readonly Widget[]; } @@ -16,25 +17,106 @@ export interface SortBlock { const DRAG_THRESHOLD = 4; const FRAME_KEY = 'block-drag'; const DIMMED = 0.4; +/** The block under a lit grip: a wash of the accent the drop line is drawn in. */ +const HOVER_ALPHA = 0.15; /** * Lets the blocks of one column be reordered by dragging their grips (#310). Unlike a * `SortableList` the rows are ordinary widgets of any height – a struct is its head with its fields - * below – so a block is told by the rows it covers. While dragging, the block is dimmed, a line - * shows where it lands, and a scrolled column follows the pointer to its edge. `move` gets the - * indices the model takes, the block already taken out. + * below – so a block is told by the rows it covers. A grip shows its dots, and the pointer on it + * lights them and backs the block that would go with it. While dragging, the block is dimmed, a + * line shows where it lands, and a scrolled column follows the pointer to its edge; the other grips + * pass the pointer by. `move` gets the indices the model takes, the block already taken out. */ export function sortBlocks(blocks: readonly SortBlock[], move: (from: number, to: number) => void): void { if (blocks.length < 2) return; - blocks.forEach((block, index) => { - block.grip.eventMode = 'static'; - block.grip.cursor = 'grab'; - block.grip.on('pointerdown', (e: FederatedPointerEvent) => { - if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move); + const hovers = blocks.map((block) => new BlockHover(block)); + blocks.forEach(({ grip }, index) => { + const hover = hovers[index]!; + grip.setGrabbable(true); + grip.cursor = 'grab'; + grip.on('pointerover', () => hover.enter()); + grip.on('pointerout', () => hover.leave()); + grip.once('destroyed', () => hover.hide()); + grip.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move, hovers); }); }); } +/** The answer to the pointer on a grip: its dots lit, the block's rows backed. Held while dragging. */ +class BlockHover { + private over = false; + private held = false; + private back: Graphics | null = null; + + constructor(private readonly block: SortBlock) {} + + enter(): void { + this.over = true; + if (this.held) return; + this.block.grip.setLit(true); + this.show(); + } + + leave(): void { + this.over = false; + if (this.held) return; + this.block.grip.setLit(false); + this.hide(); + } + + /** + * A drag in the column took over: no grip answers the pointer until it ends, and each shows the + * hand that holds. Only the dragged one stays lit, its block is dimmed instead. + */ + hold(dragged: boolean): void { + this.held = true; + this.hide(); + this.block.grip.setLit(dragged); + this.block.grip.cursor = 'grabbing'; + } + + release(): void { + this.held = false; + const { grip } = this.block; + if (grip.destroyed) return; + grip.cursor = 'grab'; + grip.setLit(this.over); + if (this.over) this.show(); + } + + private show(): void { + const column = this.block.rows[0]?.parent; + if (!column) return; + this.back ??= new Graphics(); + const { left, right, top, bottom } = blockBox(column, this.block); + this.back + .clear() + .rect(left, top, right - left, bottom - top) + .fill({ color: Theme.colors.accent, alpha: HOVER_ALPHA }); + column.addChildAt(this.back, 0); + requestRender(); + } + + hide(): void { + if (!this.back) return; + if (!this.back.destroyed) this.back.destroy(); + this.back = null; + requestRender(); + } +} + +/** The block's box in its column, from the grip's indent: a field stays inside its struct. */ +function blockBox(column: Container, { grip, rows }: SortBlock) { + return { + left: column.toLocal(grip.getGlobalPosition()).x, + right: Math.max(...rows.map((row) => row.x + row.w)), + top: Math.min(...rows.map((row) => row.y)), + bottom: Math.max(...rows.map((row) => row.y + row.h)), + }; +} + class BlockDrag { private readonly column: Container | null; private readonly scroll: ScrollContainer | null; @@ -49,6 +131,7 @@ class BlockDrag { private readonly from: number, private readonly startY: number, private readonly move: (from: number, to: number) => void, + private readonly hovers: readonly BlockHover[], ) { const block = blocks[from]!; this.column = block.rows[0]?.parent ?? null; @@ -80,6 +163,7 @@ class BlockDrag { if (!this.column) return; if (!this.active) { this.active = true; + this.hovers.forEach((hover, index) => hover.hold(index === this.from)); for (const row of this.rows) { this.alphas.push(row.alpha); row.alpha = DIMMED; @@ -110,9 +194,7 @@ class BlockDrag { const spans = this.spans(); const y = this.localY(); if (dropSlot(y, spans, this.from) === null) return; - // From the grip's own indent, so a field's line shows it stays inside its struct. - const left = column.toLocal(this.blocks[this.from]!.grip.getGlobalPosition()).x; - const right = Math.max(...this.rows.map((row) => row.x + row.w)); + const { left, right } = blockBox(column, this.blocks[this.from]!); const at = Math.round(gapLineY(blockGap(y, spans), spans)); this.line.rect(left, at - 1, Math.max(0, right - left), 2).fill(Theme.colors.accent); } @@ -141,6 +223,7 @@ class BlockDrag { if (!row.destroyed) row.alpha = this.alphas[i] ?? 1; }); if (!this.line.destroyed) this.line.destroy(); + for (const hover of this.hovers) hover.release(); if (to !== null) this.move(this.from, to); } } diff --git a/src/ui/index.ts b/src/ui/index.ts index d6fa2395..727ee3a0 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -49,6 +49,7 @@ export { Button, type ButtonOptions } from './widgets/Button'; export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip'; export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip'; export { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown'; +export { Grip } from './widgets/Grip'; export { Icon } from './widgets/Icon'; export { UI_ICON_SVG } from './icons'; export { Checkbox } from './widgets/Checkbox'; diff --git a/src/ui/widgets/Grip.ts b/src/ui/widgets/Grip.ts new file mode 100644 index 00000000..547c3679 --- /dev/null +++ b/src/ui/widgets/Grip.ts @@ -0,0 +1,63 @@ +import { Graphics } from 'pixi.js'; +import { crisp } from '../pixelGrid'; +import { Theme } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +const DOT = 2; +const DOT_GAP = 1; +const IDLE_ALPHA = 0.5; + +/** + * What a row is taken hold of by to drag it to another place (#310): its mark, the same size. Once + * `sortBlocks` makes it grabbable it carries a column of dots at its left edge, lit while the + * pointer is on it. + */ +export class Grip extends Widget { + private readonly dots = crisp(new Graphics()); + private grabbable = false; + private lit = false; + + constructor(readonly content: Widget) { + super(); + // One target for over/out: moving onto the mark must not read as leaving the grip. + this.interactiveChildren = false; + this.addChild(this.dots, content); + } + + override measure(): Size { + const size = this.content.measure(); + return { w: Math.max(this.minWidth, size.w), h: Math.max(this.minHeight, size.h) }; + } + + override layout(): void { + this.content.resize(this.w, this.h); + } + + setGrabbable(on: boolean): void { + if (this.grabbable === on) return; + this.grabbable = on; + this.invalidate(); + } + + setLit(on: boolean): void { + if (this.lit === on) return; + this.lit = on; + this.invalidate(); + } + + /** Whether the dots are drawn, and lit (test hook). */ + get dotsShown(): 'none' | 'idle' | 'lit' { + return this.grabbable ? (this.lit ? 'lit' : 'idle') : 'none'; + } + + override redraw(): void { + this.dots.clear(); + if (!this.grabbable) return; + const top = Math.round(this.h / 2 - (3 * DOT + 2 * DOT_GAP) / 2); + for (let col = 0; col < 2; col++) + for (let row = 0; row < 3; row++) + this.dots.rect(col * (DOT + DOT_GAP), top + row * (DOT + DOT_GAP), DOT, DOT); + this.dots.fill(this.lit ? Theme.colors.text : Theme.colors.textMuted); + this.dots.alpha = this.lit ? 1 : IDLE_ALPHA; + } +} diff --git a/tests/ui/block-drag.test.ts b/tests/ui/block-drag.test.ts new file mode 100644 index 00000000..01ed20ec --- /dev/null +++ b/tests/ui/block-drag.test.ts @@ -0,0 +1,85 @@ +/** + * What a sortable column shows before anything is dragged (#310): dots on every grip, and under the + * pointer the grip lit and the block that would go with it backed. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { CanvasTextMetrics, FederatedPointerEvent, Graphics } from 'pixi.js'; +import { Grip, HStack, Label, sortBlocks, Theme, VStack } from '../../src/ui'; + +vi.mock('../../src/ui/svg', () => ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg })); + +beforeAll(() => { + CanvasTextMetrics.measureText = ((text: string) => ({ + width: text.length * 7, + height: 14, + lines: [text], + lineWidths: [text.length * 7], + })) as never; +}); + +/** Three blocks: one row, two rows, one row. */ +function column(sortable = true) { + const body = new VStack(); + const blocks = [1, 2, 1].map((size, index) => { + const grip = new Grip(new Label(String(index), { width: 28 })); + const rows = Array.from({ length: size }, (_, i) => { + const row = new HStack(); + row.minHeight = Theme.rowHeight; + if (i === 0) row.add(grip); + return body.add(row); + }); + return { grip, rows }; + }); + body.resize(200, 400); + if (sortable) sortBlocks(blocks, () => {}); + return { body, blocks }; +} + +const pointer = (type: string) => Object.assign(new FederatedPointerEvent(null as never), { type }); +const backs = (body: VStack) => body.children.filter((c) => c instanceof Graphics); + +describe('the grips of a sortable column', () => { + it('show their dots, and only where the rows may be dragged', () => { + expect(column().blocks.map((b) => b.grip.dotsShown)).toEqual(['idle', 'idle', 'idle']); + expect(column(false).blocks.map((b) => b.grip.dotsShown)).toEqual(['none', 'none', 'none']); + }); + + it('light up under the pointer and back the whole block, behind its rows', () => { + const { body, blocks } = column(); + const grip = blocks[1]!.grip; + grip.emit('pointerover', pointer('pointerover')); + expect(grip.dotsShown).toBe('lit'); + expect(blocks[0]!.grip.dotsShown).toBe('idle'); + const [back] = backs(body); + expect(body.children.indexOf(back!)).toBe(0); + const bounds = (back as Graphics).bounds; + expect([bounds.minY, bounds.maxY]).toEqual([Theme.rowHeight, 3 * Theme.rowHeight]); + }); + + it('go back to rest when the pointer leaves', () => { + const { body, blocks } = column(); + const grip = blocks[1]!.grip; + grip.emit('pointerover', pointer('pointerover')); + grip.emit('pointerout', pointer('pointerout')); + expect(grip.dotsShown).toBe('idle'); + expect(backs(body)).toEqual([]); + }); + + it('let the pointer pass by while a block is dragged, and answer it again after', () => { + const { body, blocks } = column(); + const [dragged, , passed] = blocks.map((b) => b.grip); + const at = (type: string, y: number, button = 0) => + Object.assign(pointer(type), { button, global: { x: 0, y } }); + dragged!.emit('pointerdown', at('pointerdown', 0)); + dragged!.emit('globalpointermove', at('globalpointermove', 10)); + passed!.emit('pointerover', pointer('pointerover')); + expect(blocks.map((b) => b.grip.dotsShown)).toEqual(['lit', 'idle', 'idle']); + // The drop line is a Graphics too; a backing would sit behind the rows. + expect(body.children[0]).not.toBeInstanceOf(Graphics); + expect(blocks.map((b) => b.grip.cursor)).toEqual(['grabbing', 'grabbing', 'grabbing']); + dragged!.emit('pointerupoutside', at('pointerupoutside', 10)); + expect(blocks.map((b) => b.grip.dotsShown)).toEqual(['idle', 'idle', 'lit']); + expect(backs(body)).toHaveLength(1); + expect(blocks.map((b) => b.grip.cursor)).toEqual(['grab', 'grab', 'grab']); + }); +}); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 3e78c62e..ab50c68f 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -13,6 +13,7 @@ export async function structVariable(t) { click, clickText, drag, + mouse, press, typeText, mutate, @@ -232,6 +233,16 @@ export async function structVariable(t) { const target = await json(`__gm.inspectorMark(${JSON.stringify(onto)})`); await drag(grip.x, grip.y, target.x, target.y - 6); }; + const count = await json('__gm.inspectorMark("count")'); + await mouse('mouseMoved', count.x, count.y); + await mouse('mouseMoved', count.x + 1, count.y); + await sleep(50); + const lit = await json('__gm.inspectorMark("count")'); + check( + 'a mark that drags shows its dots, lit under the pointer', + count.dots === 'idle' && lit.dots === 'lit', + `${count.dots} -> ${lit.dots}`, + ); await dragMark('count', 'piece'); check( 'dragging a variable by its mark puts it in front of the struct, fields and all',