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

+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'));
}
}