The scene view had no header bar of its own, and its eye sat in the top bar. Switching between Scene and Game also showed two different pictures, since nothing tied the free view to the camera. - Theme.headerPadding (4) replaces Theme.padding in the top bar and in TabHeader, for the padding and for minHeight. Both bars shrink from 40 to 32 px. Nothing else uses the new value. - The new ScenePane wraps the scene view in a header bar with Fit and the visibility eye. The eye is gone from the top bar, which keeps the menu, undo and redo. - Fit with an active camera shows the world exactly as the Game tab does at its chosen step: the same zoom, and the camera centre on the same screen pixel. The rule is the pure freeViewOfCamera in viewport/view.ts, which also mirrors the letterbox snapping. - Fit without a camera frames all content on a whole step, as before. - New hook __gm.sceneHeaderAt(name). e2e 46g opens the eye there. - The doc comment above Theme.treeIndent went away in the working tree along with this. It is committed as found. Not checked: the two views only match while they have the same size. With the tilemap panel open the scene view is narrower, and Auto may pick a smaller step there. Tests: - ./check.sh: 178 files, 2575 tests, CHECK EXIT 0 - 2 new unit tests in view.test.ts: three cameras times four steps land on the same screen point as in the Game tab, Auto stays whole - e2e 3h (2 checks): a real click on Fit at Auto and at Fit, crate, camera frame and view box match in both tabs - GM_STEPS=1-3,46: ALL PASSED, 94 checks - Counter-probes: without the centre offset the unit test fails, with Fit ignoring the camera both 3h checks fail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import { Button, drawHeaderBar, 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 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. */
|
||
togglePause(): void;
|
||
/** The account menu at the far right: who is signed in, changing the password, signing out. */
|
||
openAccountMenu(anchor: Widget): void;
|
||
}
|
||
|
||
const SAVE_LABELS: Record<SaveStatus, string> = {
|
||
saved: 'Saved',
|
||
dirty: 'Unsaved changes',
|
||
saving: 'Saving…',
|
||
error: 'Save failed',
|
||
conflict: 'Conflict – reload',
|
||
};
|
||
|
||
/** 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. Everything with words is behind the menu: the project name and
|
||
* the start-scene switch in Project settings, the project list in Open project. The scene is
|
||
* picked where the scenes are, in the Assets panel.
|
||
*/
|
||
export class Toolbar extends HStack {
|
||
private readonly bg = new Graphics();
|
||
private readonly menuButton: Button;
|
||
private readonly undoButton: Button;
|
||
private readonly redoButton: Button;
|
||
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,
|
||
actions: ToolbarActions,
|
||
) {
|
||
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
|
||
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
|
||
this.addChildAt(this.bg, 0);
|
||
|
||
this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton));
|
||
this.add(this.menuButton);
|
||
this.add(new Spacer(Theme.padding));
|
||
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);
|
||
this.playButton = iconButton('play', () => actions.togglePlay());
|
||
this.playButton.variant = 'primary';
|
||
this.add(this.playButton);
|
||
this.pauseButton = iconButton('pause', () => actions.togglePause());
|
||
this.add(this.pauseButton);
|
||
this.add(new Spacer(), 1);
|
||
|
||
// Flexible, not a fixed strip: it is the one thing here that may shrink, and with the account
|
||
// button (#177) the row does not fit a narrow window otherwise.
|
||
this.notice = new Label('', { style: 'small', align: 'right' });
|
||
this.add(this.notice, 1);
|
||
this.status = new Label('', { style: 'small', align: 'right' });
|
||
this.status.minWidth = 110;
|
||
this.add(this.status);
|
||
this.fps = new Label('– fps', { style: 'small', align: 'right' });
|
||
this.fps.minWidth = 56;
|
||
this.add(this.fps);
|
||
// Far right, where the account lives. A menu rather than a Logout button (#182): it holds
|
||
// everything about the account without asking the row for more width, which it has none of.
|
||
const account = new Dropdown('Account', { onClick: () => actions.openAccountMenu(account) });
|
||
this.add(account);
|
||
|
||
state.on('project', () => this.refresh());
|
||
state.on('load', () => this.refresh());
|
||
state.on('mode', () => this.refresh());
|
||
state.on('save', () => this.refresh());
|
||
state.on('notice', () => this.notice.setText(state.notice));
|
||
this.refresh();
|
||
}
|
||
|
||
/** 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 {
|
||
play: this.playIcon,
|
||
pause: this.pauseIcon,
|
||
playEnabled: this.playButton.enabled,
|
||
pauseEnabled: this.pauseButton.enabled,
|
||
};
|
||
}
|
||
|
||
/** The icon buttons, so a check can click what carries no 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. */
|
||
setFps(fps: number | null): void {
|
||
this.fps.setText(fps === null ? '– fps' : `${fps} fps`);
|
||
}
|
||
|
||
private refresh(): void {
|
||
const { state } = this;
|
||
const editing = state.mode === 'edit';
|
||
// 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.playIcon = editing ? 'play' : 'stop';
|
||
this.playButton.setIcon(TOOLBAR_ICON_SVG[this.playIcon]);
|
||
this.playButton.variant = editing ? 'primary' : 'danger';
|
||
this.playButton.setEnabled(state.sceneToPlay() !== undefined);
|
||
this.playButton.invalidate();
|
||
this.pauseIcon = state.paused ? 'play' : 'pause';
|
||
this.pauseButton.setIcon(TOOLBAR_ICON_SVG[this.pauseIcon]);
|
||
this.pauseButton.setEnabled(!editing);
|
||
this.pauseButton.invalidate();
|
||
this.status.setText(SAVE_LABELS[state.saveStatus]);
|
||
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
|
||
}
|
||
|
||
/**
|
||
* The row lays itself out first, then the two run buttons are moved onto the middle of the bar.
|
||
* The row alone cannot put them there: the two spacers around them share the free space with the
|
||
* flexible notice, so each gets a third of it and the pair ends up left of centre. Middle means
|
||
* the middle of the bar here, not the middle of what the row had left over. It never travels
|
||
* past the notice, so a narrow window pushes the pair back rather than one label over another.
|
||
*/
|
||
override layout(): void {
|
||
super.layout();
|
||
const width = this.pauseButton.x + this.pauseButton.w - this.playButton.x;
|
||
const leftMost = this.redoButton.x + this.redoButton.w + this.gap;
|
||
const rightMost = this.notice.x - this.gap - width;
|
||
const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost)));
|
||
const shift = x - this.playButton.x;
|
||
this.playButton.position.set(this.playButton.x + shift, this.playButton.y);
|
||
this.pauseButton.position.set(this.pauseButton.x + shift, this.pauseButton.y);
|
||
}
|
||
|
||
override redraw(): void {
|
||
drawHeaderBar(this.bg, this.w, this.h);
|
||
}
|
||
}
|