diff --git a/README.md b/README.md index 900d0f7d..9acf43ca 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,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, 1138 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1147 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/CollisionLayersDialog.ts b/src/editor/CollisionLayersDialog.ts deleted file mode 100644 index 61f200bd..00000000 --- a/src/editor/CollisionLayersDialog.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; -import { Button, HStack, Label, TextField, Theme, VStack } from '../ui'; -import { FormDialog } from './FormDialog'; - -/** - * The project's collision layers (#152). Colliders name a layer by its *number*, so a layer taken - * out of the middle would silently move every collider below it onto another one; only the last - * layer can be removed, and the list is otherwise grown at the end. - */ -export class CollisionLayersDialog extends FormDialog { - private names: string[]; - private readonly rows = new VStack({ gap: Theme.gap }); - private readonly addButton: Button; - - constructor(names: readonly string[], onApply: (names: string[]) => void) { - super(320, 'Collision layers'); - this.names = [...names]; - this.content.add( - new Label('Two colliders meet only when each one names the layer of the other.', { - style: 'muted', - }), - ); - this.content.add(this.rows); - this.addButton = new Button('Add layer', { onClick: () => this.addLayer() }); - this.content.add(this.formRow(this.addButton)); - this.addButtons('Apply', 'primary', () => onApply(this.names)); - this.build(); - } - - /** The names as the fields stand (test hook). */ - layerNames(): string[] { - return [...this.names]; - } - - private addLayer(): void { - if (this.names.length >= MAX_COLLISION_LAYERS) return; - this.names.push(layerFallback(this.names.length)); - this.build(); - } - - private build(): void { - this.rows.removeChildren(); - for (const [i, name] of this.names.entries()) { - const field = new TextField(name, { - onChange: (value) => { - // An empty name would leave the layer nameless, which `collisionLayersOf` reads back as - // its number – so the field shows that number rather than staying blank. - const name = String(value).trim() || layerFallback(i); - this.names[i] = name; - if (name !== String(value)) field.setValue(name); - }, - }); - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.rowHeight; - row.add(this.fieldLabel(String(i))); - row.add(field, 1); - // Only the last one: removing any other would move every collider below it onto a new layer. - if (i > 0 && i === this.names.length - 1) - row.add( - new Button('×', { - variant: 'ghost', - width: Theme.controlHeight, - onClick: () => { - this.names.pop(); - this.build(); - }, - }), - ); - this.rows.add(row); - } - this.addButton.setEnabled(this.names.length < MAX_COLLISION_LAYERS); - // A row more or less makes the dialog taller or shorter; `resize` only lays out on a change, - // which is exactly what happened here. - const size = this.measure(); - this.resize(size.w, size.h); - } -} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 85231f19..ae18b074 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -30,7 +30,8 @@ import { SceneViewport } from './viewport/SceneViewport'; import { TAB_OF_ASSET } from './assetTabs'; import { attachAutosave } from './autosave'; import { PasswordDialog } from './auth/PasswordDialog'; -import type { CollisionLayersDialog } from './CollisionLayersDialog'; +import type { OpenProjectDialog } from './OpenProjectDialog'; +import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; import { ContainerEditor } from './container/ContainerEditor'; import { EditorRun } from './EditorRun'; @@ -273,8 +274,7 @@ export class Editor implements PlayHost { this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); this.toolbar = new Toolbar(state, { - openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor), - openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor), + openMainMenu: (anchor) => this.projects.openMainMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePause: () => this.play.togglePause(), openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), @@ -461,9 +461,14 @@ export class Editor implements PlayHost { return this.passwordDialog.current(); } - /** The open "Collision layers" dialog (#152), for the check scripts. */ - collisionLayersDialog(): CollisionLayersDialog | null { - return this.projects.collisionLayersDialog(); + /** The open "Project settings" dialog (#183), for the check scripts. */ + projectSettingsDialog(): ProjectSettingsDialog | null { + return this.projects.projectSettings(); + } + + /** The open "Open project" dialog (#183), for the check scripts. */ + projectPickerDialog(): OpenProjectDialog | null { + return this.projects.projectPicker(); } /** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */ @@ -755,18 +760,19 @@ export class Editor implements PlayHost { return this.editorRun.debugInfo(); } - /** What the start-scene switch says, and the switch itself – for the check scripts. */ - playFromInfo(): { mode: PlayFrom; label: string; scene: string } { - return this.toolbar.playFromInfo(); + /** Which scene Play would start, and under which of the two rules – for the check scripts. */ + playFromInfo(): { mode: PlayFrom; scene: string } { + return { mode: this.state.playFrom, scene: this.state.sceneToPlay()?.name ?? '' }; } - get playFromButton(): Widget { - return this.toolbar.playFromButton; + /** The two run buttons of the top bar (#183): what they show and whether they take a click. */ + runButtons(): ReturnType { + return this.toolbar.runButtons(); } - /** The scene selector, so a check can reach it – notably while a run is on (test hook). */ - get sceneButton(): Widget { - return this.toolbar.sceneButton; + /** The top bar's icon buttons by name, so a check can click what carries no caption. */ + get toolbarControls(): Toolbar['controls'] { + return this.toolbar.controls; } /** diff --git a/src/editor/OpenProjectDialog.ts b/src/editor/OpenProjectDialog.ts new file mode 100644 index 00000000..eedad70b --- /dev/null +++ b/src/editor/OpenProjectDialog.ts @@ -0,0 +1,91 @@ +import { Button, HStack, Label, ListView, Popup, ScrollContainer, Spacer, Theme } from '../ui'; + +/** One project as the list shows it. */ +export interface ProjectChoice { + id: string; + name: string; +} + +const LIST_WIDTH = 320; +const LIST_HEIGHT = 220; + +/** + * The project picker behind "Open project…" (#183): the list the top bar's Project dropdown used + * to be, now a dialog, because it has two things to do with a project rather than one. + * + * Delete closes this dialog and hands the choice on; the caller asks for confirmation. Two modals + * stacked over one another would leave the question standing over a list it is no longer about. + */ +export class OpenProjectDialog extends Popup { + private readonly list = new ListView(); + private readonly scroll = new ScrollContainer(); + private readonly openButton: Button; + private readonly deleteButton: Button; + private picked: string | null; + private readonly names: string[]; + + constructor( + projects: readonly ProjectChoice[], + currentId: string, + actions: { onOpen(id: string): void; onDelete(id: string): void }, + ) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); + this.picked = projects.some((p) => p.id === currentId) ? currentId : (projects[0]?.id ?? null); + this.names = projects.map((p) => p.name); + + this.content.add(new Label('Open project', { style: 'bodyBold' })); + this.list.minWidth = LIST_WIDTH; + this.list.setItems( + projects.map((p) => ({ + id: p.id, + label: p.name, + hint: p.id === currentId ? 'open' : undefined, + })), + ); + this.list.select(this.picked, true); + this.list.onSelect = (id) => { + this.picked = id; + this.refresh(); + }; + // A double click is how an asset is opened everywhere else in the editor, so it is here too. + this.list.onActivate = (id) => this.closeThen(() => actions.onOpen(id))(); + this.scroll.minWidth = LIST_WIDTH; + this.scroll.minHeight = LIST_HEIGHT; + this.scroll.setContent(this.list); + this.content.add(this.scroll); + + const buttons = new HStack({ gap: Theme.gap, align: 'center' }); + this.deleteButton = new Button('Delete', { variant: 'danger' }); + this.deleteButton.onClick = () => this.withPicked(actions.onDelete); + buttons.add(this.deleteButton); + buttons.add(new Spacer(), 1); + const cancel = new Button('Cancel'); + cancel.onClick = this.closeThen(); + buttons.add(cancel); + this.openButton = new Button('Open', { variant: 'primary' }); + this.openButton.onClick = () => this.withPicked(actions.onOpen); + buttons.add(this.openButton); + this.content.add(buttons); + this.refresh(); + } + + /** What the list shows and which row is picked (test hook). */ + info(): { names: string[]; picked: string | null } { + return { names: [...this.names], picked: this.picked }; + } + + /** Global centre of a row, so a check can click it. */ + rowPosition(id: string): { x: number; y: number } | null { + return this.list.rowGlobal(id); + } + + private withPicked(action: (id: string) => void): void { + const id = this.picked; + if (id) this.closeThen(() => action(id))(); + } + + private refresh(): void { + this.openButton.setEnabled(this.picked !== null); + this.deleteButton.setEnabled(this.picked !== null); + } +} diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index f275ab6b..b97676b1 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -1,17 +1,17 @@ /** - * Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger it. + * Loading, creating, switching and deleting projects, plus the top bar's menu that triggers it. */ -import { SetActiveSceneCommand } from '../core/commands/assetCommands'; -import { SetCollisionLayersCommand } from '../core/commands/projectCommands'; -import { listScenes } from '../core/model/Project'; +import { RenameProjectCommand, SetCollisionLayersCommand } from '../core/commands/projectCommands'; import type { ProjectStore } from '../core/store/ProjectStore'; -import { Dialog, Menu, type MenuEntry, type MenuItem, type Widget } from '../ui'; -import { CollisionLayersDialog } from './CollisionLayersDialog'; +import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; +import { OpenProjectDialog } from './OpenProjectDialog'; +import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'; import type { EditorState } from './state'; export class ProjectSession { - private readonly layersDialog = dialogSlot(); + private readonly settingsDialog = dialogSlot(); + private readonly pickerDialog = dialogSlot(); /** * `beforeSwitch` runs before a different project is loaded or the current one is deleted: a @@ -66,65 +66,86 @@ export class ProjectSession { await this.open(created.project.id); } - private async deleteCurrent(): Promise { - this.beforeSwitch(); - await this.store.delete(this.state.project.id); - await this.openFirstOrCreate(); + private async deleteProject(id: string): Promise { + // Only the project being replaced needs the run stopped; deleting another one leaves the + // loaded project, and the run with it, exactly where it was. + if (id === this.state.project.id) this.beforeSwitch(); + await this.store.delete(id); + if (id === this.state.project.id) await this.openFirstOrCreate(); } - openProjectMenu(anchor: Widget): void { - const open = async () => { - const list = await this.store.list(); - const items: MenuEntry[] = [ - { label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) }, - ...list.map((p) => ({ - label: p.id === this.state.project.id ? `● ${p.name}` : p.name, - onSelect: () => void this.open(p.id), - disabled: p.id === this.state.project.id, - })), - { separator: true }, - { label: 'Collision layers…', onSelect: () => this.openCollisionLayers() }, - { separator: true }, - { - label: 'Delete this project…', - danger: true, - onSelect: () => - new Dialog( - `Delete project "${this.state.project.name}"?`, - 'All assets and scenes are removed permanently.', - [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() }, - ], - ).openCentered(), - }, - ]; - new Menu(items).openBelow(anchor); - }; - void open().catch((error) => this.state.setNotice(String(error), 'error')); + /** The one menu of the top bar (#183): everything that is about the project rather than a scene. */ + openMainMenu(anchor: Widget): void { + const items: MenuEntry[] = [ + { label: 'New project', onSelect: () => void this.run(this.createNext()) }, + { label: 'Open project…', onSelect: () => this.openProjectPicker() }, + { separator: true }, + { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, + ]; + new Menu(items).openBelow(anchor); } - /** The project's named collision layers (#152); one dialog at a time, like the other forms. */ - openCollisionLayers(): CollisionLayersDialog { - return this.layersDialog.open( - new CollisionLayersDialog(this.state.project.collisionLayers, (names) => - this.state.execute(new SetCollisionLayersCommand(names)), - ), + private async createNext(): Promise { + const list = await this.store.list(); + await this.create(`Project ${list.length + 1}`); + } + + /** The project list as a dialog, with Open and Delete on the one picked. */ + openProjectPicker(): void { + void this.run( + (async () => { + const list = await this.store.list(); + this.pickerDialog.open( + new OpenProjectDialog(list, this.state.project.id, { + onOpen: (id) => void this.run(this.open(id)), + onDelete: (id) => this.confirmDelete(id, list.find((p) => p.id === id)?.name ?? id), + }), + ); + })(), ); } - /** The open layers dialog, for the check scripts. */ - collisionLayersDialog(): CollisionLayersDialog | null { - return this.layersDialog.current(); + /** The open project picker, for the check scripts. */ + projectPicker(): OpenProjectDialog | null { + return this.pickerDialog.current(); } - openSceneMenu(anchor: Widget): void { - const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({ - label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name, - onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)), - })); - if (items.length === 0) - items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); - new Menu(items).openBelow(anchor); + private confirmDelete(id: string, name: string): void { + new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) }, + ]).openCentered(); + } + + /** + * Name, the scene Play starts and the collision layers (#152) in one dialog (#183). The name and + * the layers are undo steps of their own – one each, and only for what actually changed; which + * scene Play starts is a preference of this browser, not of the project (see `settings.ts`). + */ + openProjectSettings(): ProjectSettingsDialog { + const { project } = this.state; + const before = { + name: project.name, + playFrom: this.state.playFrom, + collisionLayers: [...project.collisionLayers], + }; + return this.settingsDialog.open( + new ProjectSettingsDialog(before, (settings) => { + const change = settingsChanges(before, settings); + if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); + if (change.collisionLayers !== undefined) + this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); + this.state.setPlayFrom(change.playFrom); + }), + ); + } + + /** The open settings dialog, for the check scripts. */ + projectSettings(): ProjectSettingsDialog | null { + return this.settingsDialog.current(); + } + + private run(work: Promise): Promise { + return work.catch((error) => this.state.setNotice(String(error), 'error')); } } diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts new file mode 100644 index 00000000..552f58ea --- /dev/null +++ b/src/editor/ProjectSettingsDialog.ts @@ -0,0 +1,182 @@ +import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; +import { Button, HStack, Label, Menu, TextField, Theme, VStack } from '../ui'; +import type { PlayFrom } from './settings'; +import { FormDialog } from './FormDialog'; + +/** What the dialog hands back when Apply is pressed. */ +export interface ProjectSettings { + name: string; + playFrom: PlayFrom; + collisionLayers: string[]; +} + +/** + * What Apply has to carry out. Only what actually changed, so opening the dialog and closing it + * with Apply is not an undo step: a rename and a change of the layers are one command each, while + * the start-scene switch is a preference of this browser and no command at all. + * + * A blank name is not a change but a fallback to the old one – a project without a name would be + * unfindable in the picker. + */ +export function settingsChanges( + before: ProjectSettings, + after: ProjectSettings, +): { name?: string; collisionLayers?: string[]; playFrom: PlayFrom } { + const name = after.name.trim() || before.name; + return { + ...(name === before.name ? {} : { name }), + ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) + ? {} + : { collisionLayers: [...after.collisionLayers] }), + playFrom: after.playFrom, + }; +} + +const PLAY_FROM_LABEL: Record = { + start: 'Start scene', + active: 'Active scene', +}; + +/** + * Everything about the project that is not an asset (#183): its name, which scene Play starts, and + * the collision layers (#152). The three used to sit in the top bar and in a menu entry of their + * own; the bar carries icons now, so this is the one place they are edited. + * + * Colliders name a layer by its *number*, so a layer taken out of the middle would silently move + * every collider below it onto another one; only the last layer can be removed, and the list is + * otherwise grown at the end. + */ +export class ProjectSettingsDialog extends FormDialog { + private projectName: string; + private playFrom: PlayFrom; + private layers: string[]; + private readonly nameField: TextField; + private readonly playFromButton: Button; + private readonly rows = new VStack({ gap: Theme.gap }); + private readonly addButton: Button; + + constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) { + super(340, 'Project settings'); + this.projectName = settings.name; + this.playFrom = settings.playFrom; + this.layers = [...settings.collisionLayers]; + + this.nameField = new TextField(this.projectName, { + placeholder: 'Project name', + // Kept as typed, blank included: Apply is what decides, and it falls back to the old name. + onChange: (value) => { + this.projectName = String(value); + }, + }); + this.content.add(this.labelledRow('Name', this.nameField)); + + this.playFromButton = new Button(PLAY_FROM_LABEL[this.playFrom], { + width: 150, + align: 'left', + onClick: () => this.openPlayFromMenu(), + }); + this.content.add(this.formRow(this.fieldLabel('Play starts'), this.playFromButton)); + this.content.add( + new Label('"Start scene" is the scene marked in Assets, "Active scene" whatever is open.', { + style: 'muted', + }), + ); + + this.content.add(new Label('Collision layers', { style: 'bodyBold' })); + this.content.add( + new Label('Two colliders meet only when each one names the layer of the other.', { + style: 'muted', + }), + ); + this.content.add(this.rows); + this.addButton = new Button('Add layer', { onClick: () => this.addLayer() }); + this.content.add(this.formRow(this.addButton)); + + this.addButtons('Apply', 'primary', () => + onApply({ + name: this.projectName, + playFrom: this.playFrom, + collisionLayers: this.layers, + }), + ); + this.buildLayers(); + } + + /** The names as the fields stand (test hook). */ + layerNames(): string[] { + return [...this.layers]; + } + + /** Name and start-scene switch as they stand (test hook). */ + values(): { name: string; playFrom: PlayFrom } { + return { name: this.projectName, playFrom: this.playFrom }; + } + + /** The start-scene switch itself, so a check can click it. */ + get playFromControl(): Button { + return this.playFromButton; + } + + /** A label and a field that takes the rest of the row – the layer rows are built the same way. */ + private labelledRow(text: string, field: TextField): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + row.add(this.fieldLabel(text)); + row.add(field, 1); + return row; + } + + private openPlayFromMenu(): void { + const entry = (from: PlayFrom) => ({ + label: this.playFrom === from ? `● ${PLAY_FROM_LABEL[from]}` : PLAY_FROM_LABEL[from], + onSelect: () => { + this.playFrom = from; + this.playFromButton.setText(PLAY_FROM_LABEL[from]); + }, + }); + new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton); + } + + private addLayer(): void { + if (this.layers.length >= MAX_COLLISION_LAYERS) return; + this.layers.push(layerFallback(this.layers.length)); + this.buildLayers(); + } + + private buildLayers(): void { + this.rows.removeChildren(); + for (const [i, name] of this.layers.entries()) { + const field = new TextField(name, { + onChange: (value) => { + // An empty name would leave the layer nameless, which `collisionLayersOf` reads back as + // its number – so the field shows that number rather than staying blank. + const name = String(value).trim() || layerFallback(i); + this.layers[i] = name; + if (name !== String(value)) field.setValue(name); + }, + }); + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + row.add(this.fieldLabel(String(i))); + row.add(field, 1); + // Only the last one: removing any other would move every collider below it onto a new layer. + if (i > 0 && i === this.layers.length - 1) + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => { + this.layers.pop(); + this.buildLayers(); + }, + }), + ); + this.rows.add(row); + } + this.addButton.setEnabled(this.layers.length < MAX_COLLISION_LAYERS); + // A row more or less makes the dialog taller or shorter; `resize` only lays out on a change, + // which is exactly what happened here. + const size = this.measure(); + this.resize(size.w, size.h); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a8c6033d..47abba23 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -209,8 +209,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** The project's collision layers (#152), and the names the open dialog holds (null when shut). */ collisionLayers: () => ({ project: [...state.project.collisionLayers], - dialog: editor.collisionLayersDialog()?.layerNames() ?? null, + dialog: editor.projectSettingsDialog()?.layerNames() ?? null, }), + /** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */ + projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, + /** Where to click the start-scene switch inside that dialog. */ + projectSettingsPlayFromAt: () => { + const dialog = editor.projectSettingsDialog(); + return dialog ? centreOf(dialog.playFromControl) : null; + }, + /** The "Open project" dialog (#183): the projects it lists and the row picked. */ + projectPicker: () => editor.projectPickerDialog()?.info() ?? null, + /** Where to click a project's row in that dialog. */ + projectPickerRowAt: (id: string) => editor.projectPickerDialog()?.rowPosition(id) ?? null, /** The dividers left and right of the centre column – dragging them narrows the tab row. */ panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), /** The left column: asset panel on top, scene objects below, and the divider between them. */ @@ -594,11 +605,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, world: () => editor.worldSnapshot(), playInfo: () => editor.playInfo(), - /** The start-scene switch: what it says, and where to click it (its caption is elided). */ + /** Which scene Play would start, and under which of the two rules (#183: set in the settings). */ playFrom: () => editor.playFromInfo(), - playFromAt: () => centreOf(editor.playFromButton), - /** The scene selector: where it is, and whether it takes a click (it does, play mode or not). */ - sceneSelect: () => ({ ...centreOf(editor.sceneButton), enabled: editor.sceneButton.enabled }), + /** The two run buttons of the top bar: the picture each wears, and whether it takes a click. */ + runButtons: () => editor.runButtons(), + /** Where to click one of the five icon buttons: menu, undo, redo, play, pause. */ + toolbarAt: (name: keyof Editor['toolbarControls']) => centreOf(editor.toolbarControls[name]), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), physics: () => physicsStatus(), diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 4fdd68ef..30015827 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,15 +1,12 @@ import { Graphics } from 'pixi.js'; -import { RenameProjectCommand } from '../../core/commands/projectCommands'; -import { listScenes } from '../../core/model/Project'; -import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui'; -import type { PlayFrom } from '../settings'; +import { Button, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; +import { TOOLBAR_ICON_SVG } from './toolbarIcons'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ export interface ToolbarActions { - /** The "Project" dropdown: list, create and delete projects. `anchor` is the button to open the menu under. */ - openProjectMenu(anchor: Widget): void; - openSceneMenu(anchor: Widget): void; + /** The one menu (#183): new project, the project picker, the project settings. */ + openMainMenu(anchor: Widget): void; /** Play in edit mode, Stop in play mode. */ togglePlay(): void; /** Hold or resume the running world; only reachable in play mode. */ @@ -26,19 +23,38 @@ const SAVE_LABELS: Record = { conflict: 'Conflict – reload', }; -/** Top bar: project, scene selector, undo/redo, Play/Stop, save status. */ +/** Square, because an icon has no reading direction that would ask for a wider box. */ +const ICON_BUTTON = Theme.controlHeight; + +type IconName = keyof typeof TOOLBAR_ICON_SVG; + +/** + * An icon button. The caption stays empty, so the icon sits in the middle of the square rather + * than beside the gap a label would need. + */ +function iconButton(name: IconName, onClick: () => void): Button { + return new Button('', { width: ICON_BUTTON, icon: TOOLBAR_ICON_SVG[name], onClick }); +} + +/** + * Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on + * the right – icons, no captions. What it used to spell out lives in the menu now: the project + * name and the start-scene switch in Project settings, the project list in Open project, and the + * scene is picked where the scenes are, in the Assets panel. + */ export class Toolbar extends HStack { private readonly bg = new Graphics(); - private readonly projectName: TextField; - private readonly sceneSelect: Dropdown; + private readonly menuButton: Button; private readonly undoButton: Button; private readonly redoButton: Button; - private readonly playFrom: Dropdown; private readonly playButton: Button; private readonly pauseButton: Button; private readonly status: Label; private readonly notice: Label; private readonly fps: Label; + /** The picture each run button currently wears; `refresh` swaps them with the mode. */ + private playIcon: IconName = 'play'; + private pauseIcon: IconName = 'pause'; constructor( private readonly state: EditorState, @@ -48,49 +64,19 @@ export class Toolbar extends HStack { this.minHeight = Theme.controlHeight + Theme.padding * 2; this.addChildAt(this.bg, 0); - const projectButton = new Dropdown('Project', { onClick: () => actions.openProjectMenu(projectButton) }); - this.add(projectButton); - this.projectName = new TextField('', { - width: 180, - placeholder: 'Project name', - onChange: (name) => { - if (name.trim()) state.execute(new RenameProjectCommand(name.trim())); - else this.projectName.setValue(state.project.name); - }, - }); - this.add(this.projectName); + this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton)); + this.add(this.menuButton); this.add(new Spacer(Theme.padding)); - - this.add(new Label('Scene', { style: 'muted' })); - this.sceneSelect = new Dropdown('—', { - width: 160, - onClick: () => actions.openSceneMenu(this.sceneSelect), - }); - this.add(this.sceneSelect); - this.add(new Spacer(Theme.padding)); - - this.undoButton = new Button('Undo', { onClick: () => state.undo() }); - this.redoButton = new Button('Redo', { onClick: () => state.redo() }); + this.undoButton = iconButton('undo', () => state.undo()); + this.redoButton = iconButton('redo', () => state.redo()); this.add(this.undoButton); this.add(this.redoButton); this.add(new Spacer(), 1); - // Next to Play, because it answers a question about Play: which scene does this start? - this.playFrom = new Dropdown('—', { - width: 130, - onClick: () => this.openPlayFromMenu(), - }); - this.add(this.playFrom); - this.playButton = new Button('▶ Play', { - variant: 'primary', - width: 110, - onClick: () => actions.togglePlay(), - }); + this.playButton = iconButton('play', () => actions.togglePlay()); + this.playButton.variant = 'primary'; this.add(this.playButton); - this.pauseButton = new Button('❚❚ Pause', { - width: 110, - onClick: () => actions.togglePause(), - }); + this.pauseButton = iconButton('pause', () => actions.togglePause()); this.add(this.pauseButton); this.add(new Spacer(), 1); @@ -117,31 +103,25 @@ export class Toolbar extends HStack { this.refresh(); } - /** The start-scene switch for the check scripts: what it says, and where to click it. */ - playFromInfo(): { mode: PlayFrom; label: string; scene: string } { + /** What the two run buttons show right now (test hook) – they carry no text to read. */ + runButtons(): { play: IconName; pause: IconName; playEnabled: boolean; pauseEnabled: boolean } { return { - mode: this.state.playFrom, - label: this.playFrom.text, - scene: this.state.sceneToPlay()?.name ?? '', + play: this.playIcon, + pause: this.pauseIcon, + playEnabled: this.playButton.enabled, + pauseEnabled: this.pauseButton.enabled, }; } - /** The switch itself, so a check can open it without hunting for its (elided) caption. */ - get playFromButton(): Dropdown { - return this.playFrom; - } - - /** The scene selector, for the check scripts – its caption is a scene name, not a fixed label. */ - get sceneButton(): Dropdown { - return this.sceneSelect; - } - - private openPlayFromMenu(): void { - const mark = (from: PlayFrom, text: string) => (this.state.playFrom === from ? `● ${text}` : text); - new Menu([ - { label: mark('start', 'Start scene'), onSelect: () => this.state.setPlayFrom('start') }, - { label: mark('active', 'Active scene'), onSelect: () => this.state.setPlayFrom('active') }, - ]).openBelow(this.playFrom); + /** The five icon buttons, so a check can click what no longer carries a caption. */ + get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> { + return { + menu: this.menuButton, + undo: this.undoButton, + redo: this.redoButton, + play: this.playButton, + pause: this.pauseButton, + }; } /** Frame rate readout; `null` while no measurement is available yet. */ @@ -151,31 +131,19 @@ export class Toolbar extends HStack { private refresh(): void { const { state } = this; - this.projectName.setValue(state.project.name); - const scene = state.activeScene; - const scenes = listScenes(state.project); - this.sceneSelect.setText(scene ? scene.name : scenes.length ? 'Select scene' : 'No scene'); const editing = state.mode === 'edit'; - const from = state.sceneToPlay(); - // The mode, worded exactly like the menu entry it came from – a caption that reads differently - // from the thing you picked makes you check twice whether you picked the right one. - this.playFrom.setText(state.playFrom === 'active' ? 'Active scene' : 'Start scene'); - this.playFrom.setEnabled(editing); // Undo/redo work in play mode too – on the runtime copy's own history. this.undoButton.setEnabled(state.commands.canUndo); this.redoButton.setEnabled(state.commands.canRedo); - this.playButton.setText(editing ? '▶ Play' : '■ Stop'); + this.playIcon = editing ? 'play' : 'stop'; + this.playButton.setIcon(TOOLBAR_ICON_SVG[this.playIcon]); this.playButton.variant = editing ? 'primary' : 'danger'; - this.playButton.setEnabled(from !== undefined); + this.playButton.setEnabled(state.sceneToPlay() !== undefined); this.playButton.invalidate(); - this.pauseButton.setText(state.paused ? '▶ Resume' : '❚❚ Pause'); + this.pauseIcon = state.paused ? 'play' : 'pause'; + this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]); this.pauseButton.setEnabled(!editing); this.pauseButton.invalidate(); - this.projectName.setEnabled(editing); - // Stays usable in play mode: it only says which scene the editor looks at, which is the same - // thing a double-click in the Assets panel does – and that one was never blocked. The run is - // not touched by it; a running scene is swapped by `Load scene`, not from here. - this.sceneSelect.setEnabled(true); this.status.setText(SAVE_LABELS[state.saveStatus]); this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small'); } diff --git a/src/editor/panels/icons/menu.svg b/src/editor/panels/icons/menu.svg new file mode 100644 index 00000000..c26ed899 --- /dev/null +++ b/src/editor/panels/icons/menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/panels/icons/pause.svg b/src/editor/panels/icons/pause.svg new file mode 100644 index 00000000..066e89eb --- /dev/null +++ b/src/editor/panels/icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/play.svg b/src/editor/panels/icons/play.svg new file mode 100644 index 00000000..36d1b94e --- /dev/null +++ b/src/editor/panels/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/redo.svg b/src/editor/panels/icons/redo.svg new file mode 100644 index 00000000..9d9d33fb --- /dev/null +++ b/src/editor/panels/icons/redo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/stop.svg b/src/editor/panels/icons/stop.svg new file mode 100644 index 00000000..a24e2434 --- /dev/null +++ b/src/editor/panels/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/undo.svg b/src/editor/panels/icons/undo.svg new file mode 100644 index 00000000..045e92c2 --- /dev/null +++ b/src/editor/panels/icons/undo.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts new file mode 100644 index 00000000..901e5daa --- /dev/null +++ b/src/editor/panels/toolbarIcons.ts @@ -0,0 +1,12 @@ +/** + * The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left, + * play and pause in the middle, so each button is its picture and nothing else. + */ +import menu from './icons/menu.svg?raw'; +import pause from './icons/pause.svg?raw'; +import play from './icons/play.svg?raw'; +import redo from './icons/redo.svg?raw'; +import stop from './icons/stop.svg?raw'; +import undo from './icons/undo.svg?raw'; + +export const TOOLBAR_ICON_SVG = { menu, undo, redo, play, pause, stop }; diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index a0eb4dec..4d6a9d44 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -42,7 +42,7 @@ export class Button extends Widget { protected readonly padding: number; private readonly style: TextStyleToken; private readonly toggle: boolean; - private readonly icon: string | null; + private icon: string | null; private readonly iconGfx = new Graphics(); /** Colour the icon was last drawn in; it follows the label from muted to the accent text. */ private iconColor = -1; @@ -104,6 +104,17 @@ export class Button extends Widget { this.invalidate(); } + /** + * Swaps the picture – play for stop (#183). Only for a button that was built with one: an icon + * added later would change the measured width, and the row around it has already been laid out. + */ + setIcon(svg: string): void { + if (!this.icon || svg === this.icon) return; + this.icon = svg; + this.iconColor = -1; // forces the redraw below to re-tint and re-trace the new file + this.invalidate(); + } + setToggled(toggled: boolean): void { if (this.toggled === toggled) return; this.toggled = toggled; @@ -142,9 +153,14 @@ export class Button extends Widget { return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h }; } - /** Room the icon takes in front of the label, gap included; 0 without one. */ + /** + * Room the icon takes in front of the label, gap included; 0 without one. An icon button without + * a caption (#183) drops the gap – it is what stands between the icon and the label, and with no + * label to keep clear of it would push the icon off centre. + */ protected iconSpace(): number { - return this.icon ? ICON_SIZE + ICON_GAP : 0; + if (!this.icon) return 0; + return this.fullText ? ICON_SIZE + ICON_GAP : ICON_SIZE; } /** Whether the label is drawn in a bold weight (test hook). */ diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts new file mode 100644 index 00000000..5b6ad42e --- /dev/null +++ b/tests/editor/project-settings.test.ts @@ -0,0 +1,53 @@ +/** + * What "Apply" in the Project settings dialog (#183) actually carries out. The dialog itself is a + * widget and needs a canvas; the rule behind it is a pure function and is tested here. + */ +import { describe, expect, it } from 'vitest'; +import { settingsChanges, type ProjectSettings } from '../../src/editor/ProjectSettingsDialog'; + +const before: ProjectSettings = { + name: 'Demo project', + playFrom: 'start', + collisionLayers: ['Default', 'Enemies'], +}; + +describe('project settings', () => { + it('reports nothing to do when nothing was touched', () => { + expect(settingsChanges(before, { ...before, collisionLayers: ['Default', 'Enemies'] })).toEqual({ + playFrom: 'start', + }); + }); + + it('renames only when the name really differs', () => { + expect(settingsChanges(before, { ...before, name: 'Tetris' }).name).toBe('Tetris'); + expect(settingsChanges(before, { ...before, name: 'Demo project' }).name).toBeUndefined(); + }); + + it('trims the name and keeps the old one when only blanks are left', () => { + expect(settingsChanges(before, { ...before, name: ' Tetris ' }).name).toBe('Tetris'); + expect(settingsChanges(before, { ...before, name: ' ' }).name).toBeUndefined(); + }); + + it('writes the layers when one was added, renamed or dropped', () => { + const changed = (layers: string[]) => settingsChanges(before, { ...before, collisionLayers: layers }); + expect(changed(['Default', 'Enemies', 'Pickups']).collisionLayers).toEqual([ + 'Default', + 'Enemies', + 'Pickups', + ]); + expect(changed(['Default', 'Foes']).collisionLayers).toEqual(['Default', 'Foes']); + expect(changed(['Default']).collisionLayers).toEqual(['Default']); + }); + + it('hands back a copy of the layers, not the array the dialog goes on editing', () => { + const edited = ['Default', 'Foes']; + const change = settingsChanges(before, { ...before, collisionLayers: edited }); + edited.push('Later'); + expect(change.collisionLayers).toEqual(['Default', 'Foes']); + }); + + it('always reports the start-scene switch – it is a preference, not an undo step', () => { + expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active'); + expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBe('start'); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 04b597e9..a77156c6 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -6,6 +6,7 @@ import { LIST_ICON_SVG, PLUGINS_ICON_SVG, } from '../../src/editor/panels/assetIcons'; +import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -39,8 +40,30 @@ describe('icon files', () => { ['list tab', LIST_ICON_SVG], ['game tab', GAME_ICON_SVG], ['plugins tab', PLUGINS_ICON_SVG], + ...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]), ]; + it('has one icon per button of the top bar, each its own picture (#183)', () => { + expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']); + expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6); + }); + + // The bar carries no captions any more, so a picture shared with a tab or an asset type would be + // the only thing left telling the two apart – and it would not. + it('draws the top bar in pictures of its own, none of them an asset icon (#183)', () => { + const others = [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG]; + for (const svg of Object.values(TOOLBAR_ICON_SVG)) expect(others).not.toContain(svg); + }); + + // Pixi's parser draws an A in the middle of a path as a spike (the puzzle piece of #119); undo + // and redo are the arcs of this set, so they are the ones that have to keep clear of it. + it('bends the top bar arrows with cubic curves rather than arcs (#183)', () => { + for (const svg of [TOOLBAR_ICON_SVG.undo, TOOLBAR_ICON_SVG.redo]) { + expect(svg).toContain('C'); + for (const { attrs } of tags(svg).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/); + } + }); + // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only // shows the file right elsewhere – centred on the origin, it matches what the editor draws. it.each(icons)('%s is centred on its origin', (_, svg) => { diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs index f1f95b4e..245e8723 100644 --- a/tools/e2e/08-colliders.mjs +++ b/tools/e2e/08-colliders.mjs @@ -15,6 +15,7 @@ export async function colliders(t) { click, doubleClick, clickText, + clickToolbar, locate, press, typeText, @@ -135,14 +136,15 @@ export async function colliders(t) { await shot('22-trigger-collider'); // 8d. Collision layers (#152). The project starts with the one layer every collider is on; a - // second one is added and named in the dialog behind Project → "Collision layers…". - await clickText('Project'); + // second one is added and named in the Project settings dialog, which is where the layers + // moved with the top bar's rework (#183) – menu button → "Project settings…". + await clickToolbar('menu'); check( - 'the Project menu offers the collision layers', - (await json('__gm.menuRows()')).includes('Collision layers…'), + 'the top bar menu offers the project settings', + (await json('__gm.menuRows()')).includes('Project settings…'), JSON.stringify(await json('__gm.menuRows()')), ); - await clickText('Collision layers…'); + await clickText('Project settings…'); const opened = await json('__gm.collisionLayers()'); check( 'the dialog opens on the one layer a project starts with', diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 1aabe95d..0fae52df 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -38,7 +38,7 @@ export async function pause(t) { has, shot, click, - clickText, + clickToolbar, press, entity, world, @@ -78,7 +78,7 @@ export async function pause(t) { // 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps // ticking while the picture happens to look the same would pass a position check alone. - await clickText('❚❚ Pause'); + await clickToolbar('pause'); const held = await playInfo(); const heldX = await boxX(); await sleep(1000); @@ -91,12 +91,14 @@ export async function pause(t) { Math.abs(stillHeld.time - held.time) < 0.05, `x ${heldX} → ${await boxX()}, clock ${held.time.toFixed(2)} → ${stillHeld.time.toFixed(2)} over 1 s`, ); - // `locate` throws on a missing label, so the button that must be *gone* is asked for through - // the hook, which answers with null. + // Since #183 the buttons carry no caption, so what they offer is read as the picture each one + // wears: while the run is held, the pause button shows a second play sign, and the play button + // has turned into the stop square for as long as the run is on. + const heldButtons = await json('__gm.runButtons()'); check( 'the button offers to resume while it is held, and no longer to pause', - (await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false, - `resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`, + heldButtons.pause === 'play' && heldButtons.play === 'stop' && heldButtons.pauseEnabled === true, + JSON.stringify(heldButtons), ); await shot('36-paused'); @@ -261,7 +263,7 @@ export async function pause(t) { await sleep(400); const runningClock = await clock(); - await clickText('❚❚ Pause'); + await clickToolbar('pause'); const heldClock = await clock(); await sleep(800); const stillHeldClock = await clock(); diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index 9ab24a33..d9e9df67 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -42,8 +42,9 @@ export async function loadScene(t) { evaluate, mutate, click, + doubleClick, clickText, - locate, + clickToolbar, press, play, stop, @@ -53,10 +54,19 @@ export async function loadScene(t) { } = t; const playInfo = () => json('__gm.playInfo()'); const playFrom = () => json('__gm.playFrom()'); - /** The switch is opened by position: its caption is elided at the width it has. */ - const openPlayFrom = async () => { - const at = await json('__gm.playFromAt()'); + /** + * The start-scene switch sits in the Project settings dialog since the top bar became icons + * (#183): menu button → "Project settings…" → the switch → the mode → Apply. + */ + const setPlayFrom = async (mode) => { + await clickToolbar('menu'); + await clickText('Project settings…'); + const at = await json('__gm.projectSettingsPlayFromAt()'); await click(at.x, at.y); + await clickText(mode); + const chosen = await json('__gm.projectSettings()'); + await clickText('Apply'); + return chosen; }; const stats = () => json('__gm.sound.stats()'); const waitFor = async (read, done, tries = 25) => { @@ -120,9 +130,7 @@ export async function loadScene(t) { 'with no start scene named, Play starts from the active one', (await json('__gm.state.project.startSceneId')) === '' && (await playFrom()).mode === 'start' && - (await playFrom()).scene === firstName && - // The caption is the mode, worded like the menu entry it came from. - (await playFrom()).label === 'Start scene', + (await playFrom()).scene === firstName, JSON.stringify(await playFrom()), ); @@ -163,24 +171,18 @@ export async function loadScene(t) { JSON.stringify(started), ); - // 35d². The scene selector keeps working while the run is on. It only moves the editor's view, - // which a double-click on a scene in the Assets panel has always done – disabling the one - // and not the other was the inconsistency. The run itself is untouched by it. - const select = await json('__gm.sceneSelect()'); - await click(select.x, select.y); - const rows = await json('__gm.menuRows()'); - // The topmost hit, because the scene's name is on its row in the Assets panel as well. - const entry = await locate('Level 2', true); - await click(entry.x, entry.y); + // 35d². Switching the scene the editor shows keeps working while the run is on. Since the top + // bar lost its scene selector (#183) the Assets panel is the one way there, and it was + // never blocked in play mode; the run itself is untouched by it. + const sceneRow = await json('__gm.assetRow("ls-scene")'); + await doubleClick(sceneRow.x, sceneRow.y); const playing = await playInfo(); check( - 'the scene selector stays usable in play mode, and the run runs on', - select.enabled === true && - rows.some((row) => (row ?? '').includes('Level 2')) && - (await json('__gm.state.project.activeSceneId')) === 'ls-scene' && + 'a scene opened from the Assets panel moves the editor in play mode, and the run runs on', + (await json('__gm.state.project.activeSceneId')) === 'ls-scene' && playing.playing === true && playing.sceneId === 'ls-scene', - JSON.stringify({ select, rows, playing }), + JSON.stringify({ sceneRow, playing }), ); // 35e. The swap itself. The score was counted in the old scene; a fresh run would show 0. @@ -219,21 +221,20 @@ export async function loadScene(t) { // 35f. The switch is the way to run what you are working on instead – and it is remembered // between runs, because it says how you work rather than what the game does. - await openPlayFrom(); - await clickText('Active scene'); + const chosen = await setPlayFrom('Active scene'); await play(); const fromActive = await playInfo(); check( 'switched to "active scene", Play runs the scene the editor shows', fromActive.sceneId === firstId && (await playFrom()).mode === 'active' && - (await playFrom()).label === 'Active scene' && + // The dialog held the new mode before Apply, and Apply left the start scene alone. + chosen.playFrom === 'active' && (await json('__gm.state.project.startSceneId')) === 'ls-scene', - JSON.stringify({ fromActive, switch: await playFrom() }), + JSON.stringify({ fromActive, chosen, switch: await playFrom() }), ); await stop(); - await openPlayFrom(); - await clickText('Start scene'); + await setPlayFrom('Start scene'); check( 'the switch goes back to the start scene', (await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2', diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs new file mode 100644 index 00000000..fc683e4a --- /dev/null +++ b/tools/e2e/46-topbar.mjs @@ -0,0 +1,117 @@ +/** + * The top bar after its rework (#183): five icon buttons and no captions at all. What it used to + * spell out moved into the one menu – the project list into "Open project…", the project name and + * the start-scene switch into "Project settings…" next to the collision layers. + * + * Runs last, and on purpose: the project picker is opened here and left again with Cancel. Opening + * or deleting a project for real would pull the project out from under every step before this one. + */ +export async function topbar(t) { + const { check, json, has, shot, clickText, clickToolbar, typeText, press, sleep, waitUntil } = t; + + await press('Escape'); + + // 46a. Nothing in the bar is written any more. The five words it used to carry are asked for as + // drawn text: the buttons are still there, only their captions are gone. + const captions = { + project: await has('Project'), + undo: await has('Undo'), + redo: await has('Redo'), + play: await has('Play'), + pause: await has('Pause'), + }; + check( + 'the top bar carries no captions any more – menu, undo, redo, play and pause are icons', + Object.values(captions).every((shown) => shown === false), + JSON.stringify(captions), + ); + const buttons = await json('__gm.runButtons()'); + check( + 'in edit mode the run buttons offer play and nothing to pause', + buttons.play === 'play' && + buttons.pause === 'pause' && + buttons.playEnabled === true && + buttons.pauseEnabled === false, + JSON.stringify(buttons), + ); + await shot('46-topbar'); + + // 46b. The one menu. Four entries, and the separator between what opens a project and what + // changes the one that is open. + await clickToolbar('menu'); + const rows = await json('__gm.menuRows()'); + check( + 'the menu button opens new project, the project picker and the project settings', + JSON.stringify(rows) === JSON.stringify(['New project', 'Open project…', null, 'Project settings…']), + JSON.stringify(rows), + ); + + // 46c. The picker lists what the account owns, with the open project picked, and Cancel leaves + // that project exactly where it was. + await clickText('Open project…'); + // The picker fetches the list before it can open, so it is waited for rather than read at once. + await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' }); + const picker = await json('__gm.projectPicker()'); + const projectId = await json('__gm.state.project.id'); + const projectName = await json('__gm.state.project.name'); + check( + '"Open project…" lists the projects with the open one picked', + picker !== null && picker.picked === projectId && picker.names.includes(projectName), + JSON.stringify({ picker, projectId, projectName }), + ); + await clickText('Cancel'); + check( + 'Cancel closes the picker and leaves the project loaded', + (await json('__gm.projectPicker()')) === null && (await json('__gm.state.project.id')) === projectId, + JSON.stringify({ picker: await json('__gm.projectPicker()'), id: await json('__gm.state.project.id') }), + ); + + // 46d. The settings dialog holds the name, the start-scene switch and the collision layers of + // #152 – the three things the bar itself used to carry. + await clickToolbar('menu'); + await clickText('Project settings…'); + const settings = await json('__gm.projectSettings()'); + const layers = await json('__gm.collisionLayers()'); + check( + '"Project settings…" opens on the project as it stands', + settings?.name === projectName && + (settings.playFrom === 'start' || settings.playFrom === 'active') && + JSON.stringify(layers.dialog) === JSON.stringify(layers.project), + JSON.stringify({ settings, layers }), + ); + + // 46e. Renaming through it, and the icon buttons undoing and redoing it. The name is read off the + // project rather than written down here: CI seeds the same project under a different name. + const nameField = await t.locate(projectName); + await t.doubleClick(nameField.x, nameField.y); + await press('a', { ctrl: true }); + await typeText('Renamed by 46'); + await press('Enter'); + await clickText('Apply'); + await sleep(200); + check( + 'Apply renames the project', + (await json('__gm.state.project.name')) === 'Renamed by 46', + await json('__gm.state.project.name'), + ); + await clickToolbar('undo'); + await sleep(200); + const undone = await json('__gm.state.project.name'); + await clickToolbar('redo'); + await sleep(200); + const redone = await json('__gm.state.project.name'); + check( + 'the undo and redo buttons of the bar walk the rename back and forth', + undone === projectName && redone === 'Renamed by 46', + JSON.stringify({ undone, redone, was: projectName }), + ); + // Back to the name the rest of the world knows it by. + await clickToolbar('undo'); + await sleep(200); + check( + 'the project is handed back under its own name', + (await json('__gm.state.project.name')) === projectName, + await json('__gm.state.project.name'), + ); + await t.saved(); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 42e0a9a1..7d40a6fa 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -48,6 +48,7 @@ import { shapes } from './42-shapes.mjs'; import { queries } from './43-queries.mjs'; import { documents } from './44-documents.mjs'; import { login } from './45-login.mjs'; +import { topbar } from './46-topbar.mjs'; export const STEPS = [ hierarchy, @@ -95,4 +96,5 @@ export const STEPS = [ queries, documents, login, + topbar, ]; diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index f73a9d71..52d5c08b 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -371,13 +371,21 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { await evaluate(`(() => { ${body}; __gm.state.preview(); })()`); await settle(); }; + /** + * Clicks one of the top bar's icon buttons – menu, undo, redo, play, pause (#183). They carry no + * caption any more, so `clickText` has nothing to find and the position comes from the hook. + */ + const clickToolbar = async (name) => { + const at = await json(`__gm.toolbarAt(${JSON.stringify(name)})`); + await click(at.x, at.y); + }; /** * Starting and stopping a run build or tear down a whole world, which is more than the two frames * `settle()` grants – so these wait for the mode itself to have changed, then for a drawn frame so * the panels that follow the mode are on screen too. */ const play = async () => { - await clickText('▶ Play'); + await clickToolbar('play'); await waitUntil("__gm.state.mode === 'play'", { label: 'the run started' }); await settle(); }; @@ -444,6 +452,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { click, doubleClick, clickText, + clickToolbar, wheel, drag, dragAsset,