diff --git a/README.md b/README.md index fd657ffd..74965fd6 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, 2045 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2046 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/core/recording/cursors.ts b/src/core/recording/cursors.ts index 797f5313..73bf8970 100644 --- a/src/core/recording/cursors.ts +++ b/src/core/recording/cursors.ts @@ -1,59 +1,154 @@ /** * The mouse pointer of an editor recording: the system draws it over the canvas, so the recording - * draws its own. One shape per CSS cursor the editor uses, as an SVG path in CSS pixels, filled dark - * and outlined light like a system cursor. `hot` is the point that sits on the pointer. + * draws its own. The editor shows the same files as its CSS cursor unless General settings turn + * that off. One SVG file per shape in `cursors/`, meant to be opened and edited: its `viewBox` is + * the picture, and `data-hot="x y"` on the root is the point that sits on the pointer, in the same + * units. A unit is shown `CURSOR_SIZE` CSS pixels large. */ +import arrow from './cursors/arrow.svg?raw'; +import crosshair from './cursors/crosshair.svg?raw'; +import fist from './cursors/fist.svg?raw'; +import hand from './cursors/hand.svg?raw'; +import move from './cursors/move.svg?raw'; +import openHand from './cursors/open-hand.svg?raw'; +import resize from './cursors/resize.svg?raw'; +import text from './cursors/text.svg?raw'; + +/** + * How large the files are shown, in the editor and in a recording: 1 is a CSS pixel per unit of + * the `viewBox`. One number for all shapes, so the files keep the units they were drawn in. + */ +export const CURSOR_SIZE = 0.8; + export interface CursorShape { - path: string; + /** The SVG file as it is. */ + svg: string; + /** Its `viewBox`: where the picture lies, in the file's units. */ + box: { x: number; y: number; w: number; h: number }; hot: { x: number; y: number }; - /** Degrees the path is turned by around `hot`. */ + /** Degrees the picture is turned by around `hot`. */ turn: number; } -const ARROW = 'M0 0 L0 16 L4.2 12.2 L7 18.6 L9.6 17.5 L6.9 11.3 L12 11.3 Z'; -const DOUBLE_ARROW = 'M-11 0 L-6 -5 L-6 -2 L6 -2 L6 -5 L11 0 L6 5 L6 2 L-6 2 L-6 5 Z'; -const MOVE = - 'M0 -11 L4 -7 L1.5 -7 L1.5 -1.5 L7 -1.5 L7 -4 L11 0 L7 4 L7 1.5 L1.5 1.5 L1.5 7 L4 7 L0 11 ' + - 'L-4 7 L-1.5 7 L-1.5 1.5 L-7 1.5 L-7 4 L-11 0 L-7 -4 L-7 -1.5 L-1.5 -1.5 L-1.5 -7 L-4 -7 Z'; -const CROSS = 'M-1 -10 L1 -10 L1 -1 L10 -1 L10 1 L1 1 L1 10 L-1 10 L-1 1 L-10 1 L-10 -1 L-1 -1 Z'; -const BEAM = 'M-4 -9 L4 -9 L4 -7.5 L1 -7.5 L1 7.5 L4 7.5 L4 9 L-4 9 L-4 7.5 L-1 7.5 L-1 -7.5 L-4 -7.5 Z'; -const HAND = - 'M5 0 C6 0 7 0.8 7 2 L7 7 C7.5 6.5 9 6.5 9.5 7.5 C10 7 11.5 7 12 8 C12.5 7.6 14 7.8 14 9.5 ' + - 'L14 14 C14 16.5 12.5 18 10 18 L7 18 C5.5 18 4.5 17.2 3.5 16 L0.6 11.8 C-0.2 10.6 1.2 9.2 ' + - '2.4 10.1 L3 10.6 L3 2 C3 0.8 4 0 5 0 Z'; -const OPEN_HAND = - 'M3 7 C3 5.5 5 5.5 5 7 L5 3 C5 1.5 7 1.5 7 3 L7 2 C7 0.5 9 0.5 9 2 L9 3 C9 1.5 11 1.5 11 3 ' + - 'L11 5 C11 3.5 13 3.5 13 5 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L0.5 11.5 ' + - 'C-0.3 10.3 1.2 9 2.3 9.9 L3 10.5 Z'; -const FIST = - 'M3 8 C3 6.5 5 6.5 5 7.5 C5 6 7 6 7 7.2 C7 5.8 9 5.8 9 7.2 C9 6 11 6 11 7.5 C11 6.6 13 6.6 ' + - '13 8 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L2 13 C1.5 11 3 10 3 10 Z'; +/** The numbers of an attribute on the root element, null when it is missing or not numbers. */ +function rootNumbers(svg: string, name: string, count: number): number[] | null { + const root = /]*>/i.exec(svg)?.[0] ?? ''; + const value = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(root)?.[1]; + const numbers = value + ?.trim() + .split(/[\s,]+/) + .map(Number); + return numbers?.length === count && numbers.every(Number.isFinite) ? numbers : null; +} -const centred = (path: string, turn = 0): CursorShape => ({ path, hot: { x: 0, y: 0 }, turn }); - -const SHAPES: Record = { - default: { path: ARROW, hot: { x: 0, y: 0 }, turn: 0 }, - pointer: { path: HAND, hot: { x: 5, y: 0 }, turn: 0 }, - grab: { path: OPEN_HAND, hot: { x: 7, y: 9 }, turn: 0 }, - grabbing: { path: FIST, hot: { x: 7, y: 11 }, turn: 0 }, - move: centred(MOVE), - crosshair: centred(CROSS), - text: centred(BEAM), - 'ew-resize': centred(DOUBLE_ARROW), - 'col-resize': centred(DOUBLE_ARROW), - 'ns-resize': centred(DOUBLE_ARROW, 90), - 'row-resize': centred(DOUBLE_ARROW, 90), - 'nwse-resize': centred(DOUBLE_ARROW, 45), - 'nesw-resize': centred(DOUBLE_ARROW, -45), -}; +/** Reads a cursor file. Without a `viewBox` it is taken as 24 × 24, without `data-hot` as 0 0. */ +export function readCursor(svg: string, turn = 0): CursorShape { + const [x, y, w, h] = rootNumbers(svg, 'viewBox', 4) ?? [0, 0, 24, 24]; + const [hx, hy] = rootNumbers(svg, 'data-hot', 2) ?? [0, 0]; + return { svg, box: { x: x!, y: y!, w: w!, h: h! }, hot: { x: hx!, y: hy! }, turn }; +} /** - * The shape for a CSS `cursor` value as the canvas has it, null for `none`. A value with images - * (`url(…) 4 4, pointer`) is read by its keyword at the end; anything unknown is the arrow. + * The file with its root sized `scale` times its `viewBox`, so the browser draws it sharp at that + * size instead of stretching a small picture. Whatever size the file names itself is replaced. */ +export function cursorSvgAt(shape: CursorShape, scale: number): string { + const w = shape.box.w * scale; + const h = shape.box.h * scale; + return shape.svg.replace(/]*>/i, (root) => { + const bare = root.replace(/\s(width|height)\s*=\s*["'][^"']*["']/gi, ''); + return bare.replace(/^ = { + default: ARROW, + pointer: readCursor(hand), + grab: readCursor(openHand), + grabbing: readCursor(fist), + move: readCursor(move), + crosshair: readCursor(crosshair), + text: readCursor(text), + 'ew-resize': RESIZE, + 'col-resize': RESIZE, + 'ns-resize': RESIZE_NS, + 'row-resize': RESIZE_NS, + 'nwse-resize': readCursor(resize, 45), + 'nesw-resize': readCursor(resize, -45), +}; + +/** Every shape once, to load them all before the first frame needs one. */ +export function cursorShapes(): CursorShape[] { + return [...new Set(Object.values(SHAPES))]; +} + +/** The keywords that have a shape of their own. */ +export function cursorKeywords(): string[] { + return Object.keys(SHAPES); +} + +/** + * The keyword of a CSS `cursor` value as the canvas has it: a value with images (`url(…) 4 4, + * pointer`) is read by its keyword at the end, and Pixi's `inherit` or no value is the default. + */ +export function cursorKeyword(css: string): string { + const keyword = css.split(',').at(-1)!.trim().toLowerCase(); + return keyword === '' || keyword === 'inherit' ? 'default' : keyword; +} + +/** The shape for a CSS `cursor` value as the canvas has it, null for `none`; anything unknown is the arrow. */ export function cursorShape(css: string): CursorShape | null { - const keyword = css.split(',').at(-1)!.trim().toLowerCase(); + const keyword = cursorKeyword(css); if (keyword === 'none') return null; - return SHAPES[keyword] ?? SHAPES.default!; + return SHAPES[keyword] ?? ARROW; +} + +/** + * A CSS `cursor` value that shows the file for `keyword` and falls back to the keyword itself: + * `url(…) x y, keyword`, `size` CSS pixels per unit. A turned shape is turned inside a picture + * large enough to hold it, and whatever an editor put before the `` root (an XML prolog, + * comments) is left out. + */ +export function cursorCss( + keyword: string, + shape: CursorShape = SHAPES[keyword] ?? ARROW, + size = CURSOR_SIZE, +): string { + const { box, hot, turn } = shape; + const angle = (turn * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + // The corners in CSS pixels, so the picture is rounded to whole pixels at the size it is shown. + const corners = [ + [box.x, box.y], + [box.x + box.w, box.y], + [box.x, box.y + box.h], + [box.x + box.w, box.y + box.h], + ].map(([x, y]) => ({ + x: (hot.x + (x! - hot.x) * cos - (y! - hot.y) * sin) * size, + y: (hot.y + (x! - hot.x) * sin + (y! - hot.y) * cos) * size, + })); + // Rounded to whole pixels outwards; 1e-9 keeps a corner at 7.000000001 from growing the picture. + const left = Math.floor(Math.min(...corners.map((c) => c.x)) + 1e-9); + const top = Math.floor(Math.min(...corners.map((c) => c.y)) + 1e-9); + const w = Math.ceil(Math.max(...corners.map((c) => c.x)) - 1e-9) - left; + const h = Math.ceil(Math.max(...corners.map((c) => c.y)) - 1e-9) - top; + const units = (n: number): number => Math.round((n / size) * 1e6) / 1e6; + const file = cursorSvgAt(shape, 1); + const inner = file + .slice(file.search(/]*>/i, (root) => + root.replace(/\s(x|y)\s*=\s*["'][^"']*["']/gi, '').replace(/^` + + `${inner}`; + const url = `data:image/svg+xml,${encodeURIComponent(svg)}`; + return `url("${url}") ${Math.round(hot.x * size - left)} ${Math.round(hot.y * size - top)}, ${keyword}`; } diff --git a/src/core/recording/cursors/arrow.svg b/src/core/recording/cursors/arrow.svg new file mode 100644 index 00000000..fbe00740 --- /dev/null +++ b/src/core/recording/cursors/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/crosshair.svg b/src/core/recording/cursors/crosshair.svg new file mode 100644 index 00000000..26fbcffc --- /dev/null +++ b/src/core/recording/cursors/crosshair.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/fist.svg b/src/core/recording/cursors/fist.svg new file mode 100644 index 00000000..3404e057 --- /dev/null +++ b/src/core/recording/cursors/fist.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/hand.svg b/src/core/recording/cursors/hand.svg new file mode 100644 index 00000000..607d24ab --- /dev/null +++ b/src/core/recording/cursors/hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/move.svg b/src/core/recording/cursors/move.svg new file mode 100644 index 00000000..1b0055c2 --- /dev/null +++ b/src/core/recording/cursors/move.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/open-hand.svg b/src/core/recording/cursors/open-hand.svg new file mode 100644 index 00000000..f6df83e6 --- /dev/null +++ b/src/core/recording/cursors/open-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/resize.svg b/src/core/recording/cursors/resize.svg new file mode 100644 index 00000000..da732e1d --- /dev/null +++ b/src/core/recording/cursors/resize.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/recording/cursors/text.svg b/src/core/recording/cursors/text.svg new file mode 100644 index 00000000..33acb283 --- /dev/null +++ b/src/core/recording/cursors/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 8fcf6bf4..9a8a7eab 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -54,6 +54,7 @@ import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; import { generalSettings, keepGeneralSettings } from './generalSettings'; import { GeneralSettingsDialog } from './GeneralSettingsDialog'; +import { useCursorFiles } from './editorCursors'; import { ContainerEditor } from './container/ContainerEditor'; import { CompositionEditor } from './composition/CompositionEditor'; import { EditorRun } from './EditorRun'; @@ -428,6 +429,7 @@ export class Editor implements PlayHost { showBadge: (on) => this.toolbar.showRecording(on), badgeRoom: () => this.toolbar.recordingRoom(), }); + useCursorFiles(app.renderer.events, app.canvas); this.play = new PlaySession(state, this); this.editorRun = new EditorRun(state, { // The game view is the one place where no key means anything to the editor, so that is where diff --git a/src/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index 901234d1..d5f603b8 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -1,5 +1,11 @@ import { UPDATE_PRIORITY } from 'pixi.js'; -import { cursorShape, type CursorShape } from '../core/recording/cursors'; +import { + CURSOR_SIZE, + cursorShape, + cursorShapes, + cursorSvgAt, + type CursorShape, +} from '../core/recording/cursors'; import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording'; import { elapsedText, fitPicture, recordingName } from '../core/recording/recording'; import { renderCount, requestRender, Theme, toHex, ui } from '../ui'; @@ -216,7 +222,8 @@ class CanvasSource implements FrameSource { private readonly frame: OffscreenCanvas; private readonly framePen: OffscreenCanvasRenderingContext2D; private readonly pointer = new PointerTrack(); - private readonly paths = new Map(); + /** Each cursor file drawn at the size of the recording, once it has loaded. */ + private readonly cursors = new Map(); private copied = false; private ready: VideoFrame[] = []; @@ -230,7 +237,10 @@ class CanvasSource implements FrameSource { this.picturePen = this.picture.getContext('2d', { alpha: false })!; this.frame = new OffscreenCanvas(this.size.w, this.size.h); this.framePen = this.frame.getContext('2d', { alpha: false })!; - if (showsPointer) this.pointer.attach(canvas); + if (showsPointer) { + this.pointer.attach(canvas); + for (const shape of cursorShapes()) this.cursorAt(shape, this.pointerScale()); + } } open(): { w: number; h: number } { @@ -281,25 +291,44 @@ class CanvasSource implements FrameSource { const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w; const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h; - // Against the layout size: a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer. - const scale = at.w / ui().app.screen.width; - let path = this.paths.get(shape); - if (!path) this.paths.set(shape, (path = new Path2D(shape.path))); + const scale = this.pointerScale(); + const image = this.cursorAt(shape, scale); + if (!image) return; + const { box, hot } = shape; const pen = this.framePen; pen.save(); pen.translate(x, y); - pen.scale(scale, scale); pen.rotate((shape.turn * Math.PI) / 180); - pen.translate(-shape.hot.x, -shape.hot.y); - pen.lineJoin = 'round'; - pen.lineWidth = 2; - pen.strokeStyle = '#fff'; - pen.stroke(path); - pen.fillStyle = '#000'; - pen.fill(path); + pen.drawImage(image, (box.x - hot.x) * scale, (box.y - hot.y) * scale, box.w * scale, box.h * scale); pen.restore(); } + /** + * Recording pixels per unit of a cursor file: the size the editor shows it at, in CSS pixels of + * the layout, so a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer. + */ + private pointerScale(): number { + const at = fitPicture({ w: this.canvas.width, h: this.canvas.height }, this.size); + return (at.w / ui().app.screen.width) * CURSOR_SIZE; + } + + /** The cursor file drawn at `scale`, null while it loads; a new scale loads it anew. */ + private cursorAt(shape: CursorShape, scale: number): HTMLImageElement | null { + const known = this.cursors.get(shape); + if (known?.scale === scale) return known.image; + const entry: { scale: number; image: HTMLImageElement | null } = { scale, image: known?.image ?? null }; + this.cursors.set(shape, entry); + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(cursorSvgAt(shape, scale))}`; + image.decode().then( + () => { + if (this.cursors.get(shape) === entry) entry.image = image; + }, + () => console.warn('[recording] a cursor SVG does not load', shape.svg.slice(0, 120)), + ); + return entry.image; + } + collect(encode: (frame: VideoFrame) => void): void { for (const frame of this.ready.splice(0)) encode(frame); } diff --git a/src/editor/GeneralSettingsDialog.ts b/src/editor/GeneralSettingsDialog.ts index 7e528990..c6b2cad5 100644 --- a/src/editor/GeneralSettingsDialog.ts +++ b/src/editor/GeneralSettingsDialog.ts @@ -11,6 +11,8 @@ export class GeneralSettingsDialog extends FormDialog { private readonly zoomField: NumberField; private showEmpty: boolean; private readonly emptyBox: Checkbox; + private customCursor: boolean; + private readonly cursorBox: Checkbox; constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) { super(340, 'General settings'); @@ -38,12 +40,25 @@ export class GeneralSettingsDialog extends FormDialog { this.showEmpty = value; }; this.content.add(this.formRow(this.emptyBox, new Label('Show empty asset categories and types'))); + this.customCursor = settings.customCursor; + this.cursorBox = new Checkbox(this.customCursor); + this.cursorBox.onChange = (value) => { + this.customCursor = value; + }; + this.content.add(this.formRow(this.cursorBox, new Label('Custom mouse pointer'))); + this.content.add( + new Label("The pointer an editor recording draws. Off shows the system's.", { style: 'muted' }), + ); this.addButtons('Apply', 'primary', () => onApply(this.values())); } /** The settings as the fields stand (test hook). */ values(): GeneralSettings { - return { zoomTransitionMs: zoomTransitionMs(this.zoomMs), showEmptyAssetGroups: this.showEmpty }; + return { + zoomTransitionMs: zoomTransitionMs(this.zoomMs), + showEmptyAssetGroups: this.showEmpty, + customCursor: this.customCursor, + }; } /** The switch for the empty asset groups, so a check can click it. */ @@ -51,6 +66,11 @@ export class GeneralSettingsDialog extends FormDialog { return this.emptyBox; } + /** The switch for the custom mouse pointer, so a check can click it. */ + get customCursorControl(): Checkbox { + return this.cursorBox; + } + /** The zoom field itself, so a check can type into it. */ get zoomControl(): NumberField { return this.zoomField; diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 7e74a1e9..2d77d5f6 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -365,6 +365,13 @@ export function uiHooks({ app, state, editor }: HookContext) { const dialog = editor.generalSettingsDialog(); return dialog ? centreOf(dialog.emptyGroupsControl) : null; }, + /** Where to click the switch for the custom mouse pointer inside that dialog. */ + generalSettingsCustomCursorAt: () => { + const dialog = editor.generalSettingsDialog(); + return dialog ? centreOf(dialog.customCursorControl) : null; + }, + /** The CSS cursor the editor's canvas shows right now. */ + canvasCursor: () => ui().app.canvas.style.cursor, /** * The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets * 0 on every load, so a check reading the zoom after the wheel finds the step and not the way. diff --git a/src/editor/editorCursors.ts b/src/editor/editorCursors.ts new file mode 100644 index 00000000..6205d0e1 --- /dev/null +++ b/src/editor/editorCursors.ts @@ -0,0 +1,24 @@ +import type { EventSystem } from 'pixi.js'; +import { cursorCss, cursorKeyword, cursorKeywords } from '../core/recording/cursors'; +import { generalSettings, onGeneralSettings } from './generalSettings'; + +/** + * The editor's mouse pointer from the files an editor recording draws (`core/recording/cursors/`), + * as the canvas's CSS cursor: the system still draws it, the canvas never does. General settings + * switch it off, and the pointer goes back to what Pixi sets by itself. Returns the way to stop. + */ +export function useCursorFiles(events: EventSystem, canvas: HTMLCanvasElement): () => void { + const own = { ...events.cursorStyles }; + const files = Object.fromEntries(cursorKeywords().map((keyword) => [keyword, cursorCss(keyword)])); + const apply = (): void => { + const styles = events.cursorStyles; + for (const keyword of Object.keys(styles)) delete styles[keyword]; + Object.assign(styles, own, generalSettings().customCursor ? files : {}); + // Pixi sets a style only when the pointer's mode changes: the one showing now changes here. + const keyword = cursorKeyword(canvas.style.cursor); + const style = styles[keyword]; + canvas.style.cursor = typeof style === 'string' ? style : keyword; + }; + apply(); + return onGeneralSettings(apply); +} diff --git a/src/editor/generalSettings.ts b/src/editor/generalSettings.ts index a13ff9f7..38792eaf 100644 --- a/src/editor/generalSettings.ts +++ b/src/editor/generalSettings.ts @@ -12,11 +12,13 @@ export interface GeneralSettings { zoomTransitionMs: number; /** Whether the asset panel lists every category and type, faint where it holds no asset (#361). */ showEmptyAssetGroups: boolean; + /** Whether the editor shows the mouse pointer from the recording's files rather than the system's. */ + customCursor: boolean; } export const ZOOM_TRANSITION_MAX_MS = 1000; -const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false }; +const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false, customCursor: true }; let settings: GeneralSettings | null = null; const listeners = new Set<() => void>(); @@ -43,6 +45,7 @@ function read(): GeneralSettings { return { zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs), showEmptyAssetGroups: raw.showEmptyAssetGroups === true, + customCursor: raw.customCursor !== false, }; } catch { return { ...DEFAULTS }; diff --git a/tests/core/recording.test.ts b/tests/core/recording.test.ts index 8f532a49..b7c9de46 100644 --- a/tests/core/recording.test.ts +++ b/tests/core/recording.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { cursorShape } from '../../src/core/recording/cursors'; +import { + CURSOR_SIZE, + cursorCss, + cursorKeyword, + cursorKeywords, + cursorShape, + cursorShapes, + cursorSvgAt, + readCursor, +} from '../../src/core/recording/cursors'; import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording'; import { avcCodec, @@ -243,4 +252,65 @@ describe('recording the editor', () => { expect(cursorShape('grab')).not.toBe(cursorShape('default')); expect(cursorShape('none')).toBeNull(); }); + + it('reads a cursor file: its viewBox is the picture, data-hot the point on the pointer', () => { + expect(cursorShape('pointer')).toMatchObject({ + box: { x: -2, y: -2, w: 18, h: 22 }, + hot: { x: 5, y: 0 }, + }); + expect(cursorShapes()).toHaveLength(11); + // As an editor might save it: other attributes first, commas, size in millimetres. + const edited = readCursor( + '\n', + 45, + ); + expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 }); + expect(readCursor('')).toMatchObject({ + box: { x: 0, y: 0, w: 24, h: 24 }, + hot: { x: 0, y: 0 }, + }); + // Drawn at the size it is needed, whatever size the file names. + const sized = cursorSvgAt(edited, 2.5); + expect(sized).toContain(''); + }); + + it('gives the editor each cursor file as a CSS cursor, turned where the shape is turned', () => { + const svgOf = (css: string): string => + decodeURIComponent(/^url\("data:image\/svg\+xml,([^"]*)"\)/.exec(css)![1]!); + // The arrow at a unit per CSS pixel: its own picture, the hotspot counted from the picture's corner. + const arrow = cursorCss('default', undefined, 1); + const { box, hot } = cursorShape('default')!; + expect(arrow).toMatch(new RegExp(` ${hot.x - box.x} ${hot.y - box.y}, default$`)); + expect(cursorKeyword(arrow)).toBe('default'); + expect(cursorKeywords()).toEqual(expect.arrayContaining(['default', 'pointer', 'grab', 'nesw-resize'])); + // The resize arrow, 26 × 14 around its middle, turned upright: 14 × 26, the hotspot in the middle. + const upright = cursorCss('ns-resize', undefined, 1); + expect(upright).toMatch(/ 7 13, ns-resize$/); + expect(svgOf(upright)).toContain('width="14" height="26" viewBox="-7 -13 14 26"'); + expect(svgOf(upright)).toContain( + '\n\n', + ); + const css = cursorCss('grab', edited, 1); + expect(css).toMatch(/ 2 2, grab$/); + const svg = svgOf(css); + expect(svg).not.toContain(' { expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({ zoomTransitionMs: 350, showEmptyAssetGroups: false, + customCursor: true, }); }); @@ -418,12 +419,29 @@ describe('the general settings', () => { const stop = onGeneralSettings(() => (told += 1)); keepGeneralSettings({ showEmptyAssetGroups: true }); resetGeneralSettings(); - expect(generalSettings()).toEqual({ zoomTransitionMs: 200, showEmptyAssetGroups: true }); + expect(generalSettings()).toEqual({ + zoomTransitionMs: 200, + showEmptyAssetGroups: true, + customCursor: true, + }); stop(); keepGeneralSettings({ showEmptyAssetGroups: false }); expect(told).toBe(1); }); + it('shows the custom mouse pointer until switched off, and keeps the switch', () => { + const store = memoryStore(); + vi.stubGlobal('window', { localStorage: store }); + expect(generalSettings().customCursor).toBe(true); + keepGeneralSettings({ customCursor: false }); + resetGeneralSettings(); + expect(generalSettings().customCursor).toBe(false); + // Settings kept before the switch existed show it. + store.setItem('game-maker.general-settings', JSON.stringify({ zoomTransitionMs: 100 })); + resetGeneralSettings(); + expect(generalSettings().customCursor).toBe(true); + }); + it('holds the transition to whole ms from 0 to 1000', () => { expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]); expect(zoomTransitionMs('fast')).toBe(200); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index ade58874..c4179336 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -435,6 +435,49 @@ export async function topbar(t) { JSON.stringify({ general, kept }), ); + // 46i-ii. The custom mouse pointer, on by default: the canvas shows the cursor file an editor + // recording draws as its CSS cursor (Chrome took the value, the picture loads, the keyword + // stays at the end). Switched off in the dialog the system's keyword is back, on again the file. + // `json` would stringify the promise itself: this one hands back its JSON once it is settled. + const pictureLoads = async () => + JSON.parse( + await t.evaluate(`(async () => { + const url = /^url\\("([^"]*)"\\)/.exec(__gm.canvasCursor())?.[1]; + if (!url) return 'null'; + const image = new Image(); + image.src = url; + try { await image.decode(); return JSON.stringify([image.naturalWidth, image.naturalHeight]); } + catch { return '"broken"'; } + })()`), + ); + const switchCursor = async () => { + await clickToolbar('menu'); + await clickText('General settings…'); + const box = await json('__gm.generalSettingsCustomCursorAt()'); + await t.click(box.x, box.y); + await clickText('Apply'); + await sleep(100); + }; + const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() }; + await switchCursor(); + const cursorOff = { css: await json('__gm.canvasCursor()') }; + const storedOff = await json( + `JSON.parse(localStorage.getItem('game-maker.general-settings')).customCursor`, + ); + await switchCursor(); + const cursorBack = await json('__gm.canvasCursor()'); + check( + 'the custom mouse pointer is on by default as the CSS cursor, General settings switch it off and on', + general?.customCursor === true && + /^url\("data:image\/svg\+xml,.*"\) \d+ \d+, default$/.test(cursorOn.css) && + Array.isArray(cursorOn.size) && + cursorOn.size[0] > 0 && + !cursorOff.css.startsWith('url(') && + storedOff === false && + cursorBack === cursorOn.css, + JSON.stringify({ cursorOn: { ...cursorOn, css: cursorOn.css.slice(0, 40) }, cursorOff, storedOff }), + ); + await t.showTab('scene'); const rect = await json('__gm.viewportRect()'); const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };