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

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