The topbar is icons only: the leading Project dropdown, the Scene picker and the labelled Play/Pause buttons are gone and in their place stand five icon buttons - menu, undo, redo on the left, play and pause in the middle - while Notice, the save state, fps and the Account menu keep the right side; the Play button swaps its icon for a stop square while the project runs and Pause swaps for a second play sign while it is held (Button.setIcon()), and iconSpace() drops the gap between icon and label when a button has no label, which is what kept a 24 px icon button 3 px off its own centre; the menu carries New project, Open project..., a separator and Project settings..., where the picker (src/editor/OpenProjectDialog.ts) preselects the open project and marks it "open", opens on a double click and puts Delete on the left of Cancel and Open - it closes itself before it asks whether to delete, because two modals on top of each other would have left the question standing over a list it no longer speaks about - and the settings dialog (src/editor/ProjectSettingsDialog.ts) holds the name, the "Play starts" switch (Start scene / Active scene, a preference of this browser and so no command) and the collision layers from #152, which retires CollisionLayersDialog.ts and the "Collision layers..." entry; what Apply actually writes is the pure function settingsChanges(), so renaming and the layers are each their own undo step and each drop out when nothing differs; the Scene dropdown falls away without replacement, the active scene is switched by double clicking it in the Assets panel, which is the way that was there anyway; new module 46 runs last with 9 checks (no label left in the bar, the run buttons in edit mode, the four menu entries, the picker with the open project preselected, Cancel leaving the project standing, the settings dialog on the state of the project, renaming with undo and redo through the icon buttons) and it neither opens nor deletes a project, because that would pull the project out from under every earlier module; modules 8, 18 and 35 and the harness follow the new bar, 6 unit tests cover settingsChanges and 3 the icons (#183)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 16:34:14 +02:00
1 parent defd4a86d2
commit 60d0146bde
24 files changed
+751 -284

No files matched your search

-77
View File
@@ -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);
}
}
+20 -14
View File
@@ -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<Toolbar['runButtons']> {
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;
}
/**
+91
View File
@@ -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);
}
}
+79 -58
View File
@@ -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<CollisionLayersDialog>();
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
/**
* `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<void> {
this.beforeSwitch();
await this.store.delete(this.state.project.id);
await this.openFirstOrCreate();
private async deleteProject(id: string): Promise<void> {
// 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<void> {
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<void>): Promise<void> {
return work.catch((error) => this.state.setNotice(String(error), 'error'));
}
}
+182
View File
@@ -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<PlayFrom, string> = {
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);
}
}
+17 -5
View File
@@ -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(),
+54 -86
View File
@@ -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<SaveStatus, string> = {
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');
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- The one menu of the top bar (#183): three bars, the sign every application uses for it. -->
<rect x="-6" y="-5.25" width="12" height="1.75" rx="0.5" fill="currentColor" />
<rect x="-6" y="-0.875" width="12" height="1.75" rx="0.5" fill="currentColor" />
<rect x="-6" y="3.5" width="12" height="1.75" rx="0.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5" y="-6" width="3.5" height="12" rx="0.5" fill="currentColor" />
<rect x="1.5" y="-6" width="3.5" height="12" rx="0.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 250 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-4.5 -6 L6 0 L-4.5 6 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 152 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- The mirror of undo.svg, every x negated. -->
<path d="M3.5 -2 C1.5 -5.5, -4 -5.5, -5 -1.5 C-5.8 1.5, -4.5 4.3, -1.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6" />
<path d="M7.2 -4.6 L0.8 -4.6 L4 1.2 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 343 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" rx="0.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 174 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- An arrow bending back on itself. Cubic curves, not arcs: Pixi's parser draws an A in the
middle of a path as a spike (see plugins.svg). -->
<path d="M-3.5 -2 C-1.5 -5.5, 4 -5.5, 5 -1.5 C5.8 1.5, 4.5 4.3, 1.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6" />
<path d="M-7.2 -4.6 L-0.8 -4.6 L-4 1.2 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 445 B

+12
View File
@@ -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 };
+19 -3
View File
@@ -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). */