Head the groups of the inspector with bands that fold (#290)
The groups of the inspector had a small grey label as their title, in the same grid as a property row, so with many rows one group hardly stood apart from the next. Now every group has a band as its head, from edge to edge of the panel, and a click on it folds the group. - A band runs across the padding of its column in the header colour, with the title and a mark that says whether the group is open. The titles stay as they are, as answered in the issue. - A click on a band folds its group: the rows go, the band stays and the next band moves up against it. A control in the band (Open, + Variable, + Property) does its own job and folds nothing. - A placed object has a group SWITCHES under its name, asset and parent: Enabled, Keep on scene load and the switches of a behavior. An asset has SWITCHES and PROPERTIES, and a group without rows is left out. - The same bands head the tilemap panel, the Variables tab, the side of the Pixel art tab and the shape list of vector art. A list with a title (Layers, Frames, Shapes) folds with a click on its header, clear of its plus. - ui/sections.ts holds the rules as pure functions, SectionStack is the column and SectionHeader the band. Every title of the inspector goes through addTitle or sectionHeader in inspector/host.ts. Decided here: - A fold is kept for good, in localStorage of this browser. The issue asked whether for the session or for good and the answer left it open. - A fold is remembered by title, per panel. PROPERTIES folded once is folded on every object, asset and node. A number in a title does not count. - A behavior asset has all its rows under SWITCHES. A Variables asset and a state machine get a group SCOPE. - A greyed out control in a band takes the click too. Without that the click fell through to the band and folded the group. - The Curve tab has no column of this kind and is unchanged. Found on the way: - The inspector of a sprite is taller than the 800 pixel window of the checks now. The hooks that hand out a row scroll it into view first, and module 28 scrolls before it looks for a row by its text. Tests: - Gate: 202 files, 3008 tests (2982 + 26), exit 0 - e2e: no complete run for this state. Modules 28 to 61 passed alone (749 checks). A full run was cut off in the accounts module at 1492 passes without a failure, the modules after it and the smokes are missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it. - 26 unit tests in tests/editor/sections.test.ts - Four e2e checks with a real mouse, three in module 02 and one in module 48 - 25 unit counter-probes all fail tests, run on stein2 - Screenshots of the bands looked at before the last build, none after it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5ba21533d
commit
393caadc9d
22 files changed
+1072
-53
No files matched your search
@@ -45,7 +45,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, 1635 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1639 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
|
||||
|
||||
@@ -46,6 +46,13 @@ export function pixelArtHooks({ state, editor }: HookContext) {
|
||||
const at = list.addGlobal();
|
||||
return at ? { ...at, enabled: true, text: '+' } : null;
|
||||
}
|
||||
// Nor is the band it stands in (#290): where a click folds the list, and whether it is folded.
|
||||
const headed = name === 'layer:head' ? side.layers : name === 'frame:head' ? side.frames : null;
|
||||
if (headed) {
|
||||
side.reveal(headed);
|
||||
const at = headed.headGlobal();
|
||||
return at ? { ...at, h: headed.h, enabled: true, text: '', folded: headed.sectionCollapsed } : null;
|
||||
}
|
||||
const widget = byName[name];
|
||||
// A tool's options hide in their row (#201), so a hidden parent hides the control as well.
|
||||
for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import { Button, Label, Panel, TextField, Widget, activeDrag, currentMenu, ui } from '../../ui';
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Panel,
|
||||
SectionHeader,
|
||||
TextField,
|
||||
Widget,
|
||||
activeDrag,
|
||||
currentMenu,
|
||||
ui,
|
||||
} from '../../ui';
|
||||
import { Theme } from '../../ui/Theme';
|
||||
import { currentFontPicker } from '../font/FontPicker';
|
||||
import { currentGroupDialog } from '../panels/GroupNameDialog';
|
||||
@@ -59,7 +69,10 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
const row = owner?.parent;
|
||||
if (!row || !owner) return null;
|
||||
const control = row.children[row.children.indexOf(owner) + 1];
|
||||
return control instanceof Widget ? control : null;
|
||||
if (!(control instanceof Widget)) return null;
|
||||
// Taller than the window since its groups have bands (#290): the row is scrolled into view.
|
||||
inspector.reveal(control);
|
||||
return control;
|
||||
};
|
||||
/**
|
||||
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
|
||||
@@ -169,6 +182,7 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
walk(inspector);
|
||||
const label = found as Label | null;
|
||||
if (!label) return null;
|
||||
inspector.reveal(label);
|
||||
const shown = label.children.find((child): child is Text => child instanceof Text);
|
||||
const row = label.parent;
|
||||
const next = row?.children[row.children.indexOf(label) + 1];
|
||||
@@ -180,6 +194,23 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
pressable: label.eventMode === 'static',
|
||||
};
|
||||
},
|
||||
/**
|
||||
* The band of a group of rows (#290), by its title: where to click it clear of its controls, its
|
||||
* rectangle and whether the group is folded. The first one showing, or the last with `last`.
|
||||
*/
|
||||
section: (title: string, last = false) => {
|
||||
const found: SectionHeader[] = [];
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof SectionHeader && node.title === title) found.push(node);
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
const header = last ? found.at(-1) : found[0];
|
||||
if (!header) return null;
|
||||
const rect = rectOf(header);
|
||||
return { x: rect.x + 60, y: rect.y + rect.h / 2, rect, collapsed: header.sectionCollapsed };
|
||||
},
|
||||
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
|
||||
inspectorRect: () => rectOf(inspector),
|
||||
/** Where the focused widget sits on screen, or `null` – for checks on its focus border. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { Label, Panel, pressCount, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
|
||||
import { Label, Panel, pressCount, ScrollContainer, SectionStack, Theme, ui, type Widget } from '../../ui';
|
||||
import { activateAsset } from '../actions';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
@@ -77,12 +77,26 @@ export class Inspector extends Panel {
|
||||
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls until `widget` is in view, when it is one of the rows in here – the check scripts
|
||||
* reveal a row before they click it, as a user scrolls to it.
|
||||
*/
|
||||
reveal(widget: Widget): void {
|
||||
let inside = false;
|
||||
for (let at = widget.parent; at && !inside; at = at.parent) inside = at === this.host.body;
|
||||
if (!inside) return;
|
||||
const top = widget.getGlobalPosition().y - this.host.body.getGlobalPosition().y;
|
||||
this.scroll.ensureVisible(top, widget.h);
|
||||
}
|
||||
|
||||
/** For the check scripts: every list drawn inline in here, at any depth. */
|
||||
lists(): InlineList[] {
|
||||
return inlineListsIn(this.host.body);
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
// Read through the state on every call: load and play mode replace the project object.
|
||||
@@ -90,7 +104,7 @@ export class Inspector extends Panel {
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
followReference: (assetId, e) => this.followReference(assetId, e),
|
||||
listLimit: INLINE_LIMIT,
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
|
||||
import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
isRowShown,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
@@ -49,14 +50,14 @@ import {
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuEntry, Theme } from '../../ui';
|
||||
import { Button, Dropdown, Label, Menu, type MenuEntry } from '../../ui';
|
||||
import { FontPreview } from '../font/FontPreview';
|
||||
import { clearSaved, hasSaved } from '../saves';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
import type { VariablesOwner } from '../state';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
|
||||
import { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
|
||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||
@@ -131,13 +132,27 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
},
|
||||
});
|
||||
|
||||
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
|
||||
// The switches in a group of their own, as on a placed object (#290). A behavior's rows are all
|
||||
// switches: what it declares is listed under VARIABLES.
|
||||
const schema = markedAssetSchema(host.state.project, asset);
|
||||
const switches = schema.filter((def) => asset.type === 'behavior' || isSwitch(def.key));
|
||||
buildProps(host, asset, switches, 'SWITCHES');
|
||||
buildProps(
|
||||
host,
|
||||
asset,
|
||||
schema.filter((def) => !switches.includes(def)),
|
||||
'PROPERTIES',
|
||||
);
|
||||
if (isVariableOwner(asset)) buildVariables(host, asset);
|
||||
if (asset.type === 'variables') {
|
||||
addTitle(host, 'SCOPE');
|
||||
buildVariablesScope(host, asset);
|
||||
buildPersistence(host, asset);
|
||||
}
|
||||
if (asset.type === 'statemachine') buildMachineScope(host, asset);
|
||||
if (asset.type === 'statemachine') {
|
||||
addTitle(host, 'SCOPE');
|
||||
buildMachineScope(host, asset);
|
||||
}
|
||||
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
|
||||
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
|
||||
// A font has no properties at all, so without this its inspector would be the name and nothing.
|
||||
@@ -166,10 +181,15 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
|
||||
const isSwitch = (key: string): boolean => COMMON_PROPS.some((def) => def.key === key);
|
||||
|
||||
/** The rows of `schema` that are shown, under `title` – which is left out with them. */
|
||||
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void {
|
||||
const project = () => host.state.project;
|
||||
const values = resolveAssetProps(project(), asset);
|
||||
for (const def of schema.filter((d) => isRowShown(d, values))) {
|
||||
const shown = schema.filter((d) => isRowShown(d, values));
|
||||
if (shown.length > 0) addTitle(host, title);
|
||||
for (const def of shown) {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
@@ -238,8 +258,7 @@ function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): r
|
||||
*/
|
||||
export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, inTab = false): void {
|
||||
const isType = asset.type === 'struct';
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label(isType ? 'FIELDS' : 'VARIABLES', { style: 'title' }), 1);
|
||||
const header = sectionHeader(host, isType ? 'FIELDS' : 'VARIABLES');
|
||||
const add = new Dropdown(isType ? '+ Field' : '+ Variable', { variant: 'ghost' });
|
||||
const run = (command: Command, rename?: FieldRename): void =>
|
||||
void host.state.execute(isType ? new WithStructTypes(command, rename) : command);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { Label, TextField, type TextFieldOptions, type VStack } from '../../ui';
|
||||
import { Label, SectionHeader, TextField, type TextFieldOptions, type VStack } from '../../ui';
|
||||
import { foldKey, isFolded, setFolded } from '../sectionFolds';
|
||||
import type { EditorState } from '../state';
|
||||
import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory';
|
||||
|
||||
@@ -13,10 +14,18 @@ export interface InspectorHost extends FieldContext {
|
||||
readonly fields: Map<string, FieldRow>;
|
||||
/** The name field at the top, when the selection has a name. */
|
||||
nameField: TextField | null;
|
||||
/** Names the panel where its folded groups are remembered (#290); the inspector's when left out. */
|
||||
readonly foldScope?: string;
|
||||
}
|
||||
|
||||
/** The band that opens a group of rows; controls go to its right end with `add()`. Not yet in the body. */
|
||||
export function sectionHeader(host: InspectorHost, text: string): SectionHeader {
|
||||
const key = foldKey(host.foldScope ?? 'inspector', text);
|
||||
return new SectionHeader(text, { collapsed: isFolded(key), onToggle: (on) => setFolded(key, on) });
|
||||
}
|
||||
|
||||
export function addTitle(host: InspectorHost, text: string): void {
|
||||
host.body.add(new Label(text, { style: 'title' }));
|
||||
host.body.add(sectionHeader(host, text));
|
||||
}
|
||||
|
||||
export function addNote(
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
deviatesFromTemplate,
|
||||
resolveSceneObjectProps,
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button, HStack, Label, TextField, Theme } from '../../ui';
|
||||
import { Button, TextField, Theme } from '../../ui';
|
||||
import { createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { defSignature, openVariablesButton } from './assetSection';
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
linkLabel,
|
||||
type RowActions,
|
||||
} from './fieldFactory';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
import { parentRow } from './parentRow';
|
||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||
@@ -165,7 +165,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
// Content of a placed container: values compare against the template, not just the asset.
|
||||
if (target) assetLink(host, PART_OF_ROW, 'Part of', target.asset);
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
// The switches in a group of their own (#290), above everything type-specific.
|
||||
addTitle(host, 'SWITCHES');
|
||||
const resolved = resolveSceneObjectProps(host.state.project, asset, object);
|
||||
// Only a top-level object of a scene travels through a `Load scene` (#165), so only there is the row.
|
||||
const travels = object.parentId === '' && findAsset(project, holderId)?.type === 'scene';
|
||||
@@ -252,8 +253,7 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
|
||||
host.objectPicker = objectPicker(host.state, holderId, objectId, host.objectChoices);
|
||||
const behavior = asset.type === 'behavior';
|
||||
if (behavior) {
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||
const header = sectionHeader(host, 'VARIABLES');
|
||||
if (!inTab) header.add(openVariablesButton(host, { kind: 'object', holderId, objectId }));
|
||||
host.body.add(header);
|
||||
} else if (own.length > 0) addTitle(host, 'PROPERTIES');
|
||||
|
||||
@@ -37,13 +37,14 @@ import {
|
||||
Label,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
type SectionHeader,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
import { addNote, type InspectorHost, sectionHeader } from './host';
|
||||
import { kindMenu, markLabel } from './variableRows';
|
||||
|
||||
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
|
||||
@@ -164,8 +165,7 @@ function buildPickedPortSection(
|
||||
);
|
||||
const searchable = noun === 'Property';
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||||
const header = sectionHeader(host, plural.toUpperCase());
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||||
@@ -214,8 +214,7 @@ export function buildPortSection(
|
||||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||||
const header = sectionHeader(host, `${noun.toUpperCase()}S`);
|
||||
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
|
||||
if (typeName !== null) {
|
||||
host.body.add(header);
|
||||
@@ -295,7 +294,7 @@ function addShapePicker(
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
variables: readonly PropertyDef[],
|
||||
header: HStack,
|
||||
header: SectionHeader,
|
||||
): void {
|
||||
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
|
||||
if (shaped.length === 0) return;
|
||||
|
||||
@@ -30,16 +30,17 @@ import {
|
||||
Menu,
|
||||
Panel,
|
||||
ScrollContainer,
|
||||
SectionStack,
|
||||
SortableList,
|
||||
Theme,
|
||||
ui,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import type { EditorState, TilemapFocus, TilemapTool } from '../state';
|
||||
import { TilePalette } from '../tile/TilePalette';
|
||||
import { labelledRow } from '../inspector/fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from '../inspector/host';
|
||||
import { listFold } from '../sectionFolds';
|
||||
|
||||
const FOCUS: { focus: TilemapFocus; label: string }[] = [
|
||||
{ focus: 'all', label: 'All layers' },
|
||||
@@ -217,13 +218,16 @@ export class TilemapPanel extends Panel {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
project: () => this.state.project,
|
||||
liveVariables: () => null,
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
foldScope: FOLD_SCOPE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +260,9 @@ export class TilemapPanel extends Panel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the panel's folded groups are remembered, apart from the inspector's. */
|
||||
const FOLD_SCOPE = 'tilemap';
|
||||
|
||||
function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
const { state } = host;
|
||||
const paint = state.tilemapPaint;
|
||||
@@ -277,6 +284,7 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
palette: null,
|
||||
layers: new SortableList({
|
||||
title: 'Layers',
|
||||
...listFold(FOLD_SCOPE, 'Layers'),
|
||||
reversed: true,
|
||||
keepOne: true,
|
||||
onPick: (layerId) => state.setTilemapPaint({ layerId }),
|
||||
|
||||
@@ -20,15 +20,17 @@ import {
|
||||
Menu,
|
||||
NumberField,
|
||||
ScrollContainer,
|
||||
SectionHeader,
|
||||
SectionStack,
|
||||
SortableList,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { pixelCels } from '../../viewport/PixelCels';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import { foldKey, isFolded, listFold, setFolded } from '../sectionFolds';
|
||||
import type { EditorState } from '../state';
|
||||
import { PaletteGrid } from './PaletteGrid';
|
||||
import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas';
|
||||
@@ -60,7 +62,7 @@ function brushLabel(size: number): string {
|
||||
}
|
||||
|
||||
export class PixelArtSide extends ScrollContainer {
|
||||
private readonly column = new VStack({ gap: Theme.gap, padding: Theme.padding });
|
||||
private readonly column = new SectionStack({ gap: Theme.gap, padding: Theme.padding });
|
||||
/** Public for the check scripts: every control they press. */
|
||||
readonly toolButtons = new Map<PixelTool, Button>();
|
||||
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
|
||||
@@ -97,6 +99,7 @@ export class PixelArtSide extends ScrollContainer {
|
||||
this.perFrame = new FramePreview(state, 'pixelart-side');
|
||||
this.preview = new PixelPreview(state);
|
||||
const c = this.column;
|
||||
c.onFold = () => this.relayoutContent();
|
||||
|
||||
c.add(heading('Tools'));
|
||||
for (let i = 0; i < PIXEL_TOOLS.length; i += TOOLS_PER_ROW) {
|
||||
@@ -141,6 +144,7 @@ export class PixelArtSide extends ScrollContainer {
|
||||
|
||||
this.layers = new SortableList({
|
||||
title: 'Layers',
|
||||
...listFold(FOLD_SCOPE, 'Layers'),
|
||||
reversed: true,
|
||||
keepOne: true,
|
||||
onPick: (id) => this.canvas.selectLayer(id),
|
||||
@@ -176,6 +180,7 @@ export class PixelArtSide extends ScrollContainer {
|
||||
|
||||
this.frames = new SortableList({
|
||||
title: 'Frames',
|
||||
...listFold(FOLD_SCOPE, 'Frames'),
|
||||
keepOne: true,
|
||||
onPick: (id) => this.canvas.selectFrame(id),
|
||||
onMove: (from, to) =>
|
||||
@@ -422,9 +427,16 @@ export class PixelArtSide extends ScrollContainer {
|
||||
}
|
||||
}
|
||||
|
||||
function heading(text: string): Label {
|
||||
// The style the lists' own headers use, so the column reads as one.
|
||||
return new Label(text.toUpperCase(), { style: 'title' });
|
||||
/** Where the column's folded groups are remembered, apart from the inspector's. */
|
||||
const FOLD_SCOPE = 'pixelart';
|
||||
|
||||
/** The band the lists' own headers are, so the column reads as one. */
|
||||
function heading(text: string): SectionHeader {
|
||||
const key = foldKey(FOLD_SCOPE, text.toUpperCase());
|
||||
return new SectionHeader(text.toUpperCase(), {
|
||||
collapsed: isFolded(key),
|
||||
onToggle: (on) => setFolded(key, on),
|
||||
});
|
||||
}
|
||||
|
||||
function row(...widgets: Widget[]): HStack {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Which groups of rows are folded (#290), kept in this browser: a group folded once stays folded
|
||||
* for every selection that shows it, and after a reload. Best effort, like the saves – a browser
|
||||
* that refuses `localStorage` forgets with the page.
|
||||
*/
|
||||
import { Theme } from '../ui/Theme';
|
||||
|
||||
const STORAGE_KEY = 'game-maker.folded-sections';
|
||||
|
||||
let folded: Set<string> | null = null;
|
||||
|
||||
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function read(): Set<string> {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '[]');
|
||||
return new Set(Array.isArray(stored) ? stored.filter((key) => typeof key === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a group: the panel it stands in and its title, numbers left out – "3 OBJECTS SELECTED" and
|
||||
* "KEY AT FRAME 12" are the same group whatever they count.
|
||||
*/
|
||||
export function foldKey(scope: string, title: string): string {
|
||||
return `${scope}:${title.replace(/\d+/g, '#')}`;
|
||||
}
|
||||
|
||||
export function isFolded(key: string): boolean {
|
||||
folded ??= read();
|
||||
return folded.has(key);
|
||||
}
|
||||
|
||||
export function setFolded(key: string, on: boolean): void {
|
||||
folded ??= read();
|
||||
if (on) folded.add(key);
|
||||
else folded.delete(key);
|
||||
try {
|
||||
storage()?.setItem(STORAGE_KEY, JSON.stringify([...folded].sort()));
|
||||
} catch {
|
||||
// Kept for this page only.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a list with a header of its own takes to fold like the groups of the column it stands in:
|
||||
* the band reaches across the column's padding, and the fold is remembered under the list's title.
|
||||
*/
|
||||
export function listFold(
|
||||
scope: string,
|
||||
title: string,
|
||||
): { bleed: number; collapsed: boolean; onFold: (collapsed: boolean) => void } {
|
||||
const key = foldKey(scope, title.toUpperCase());
|
||||
return { bleed: Theme.padding, collapsed: isFolded(key), onFold: (on) => setFolded(key, on) };
|
||||
}
|
||||
|
||||
/** Forgets what was read, so the next question asks the browser again (tests). */
|
||||
export function resetFolds(): void {
|
||||
folded = null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { isVariableOwner } from '../../core/model/Asset';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { objectName } from '../../core/model/SceneObject';
|
||||
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
||||
import { Label, ScrollContainer, SectionStack, Theme, ui, VStack, HStack } from '../../ui';
|
||||
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
||||
import type { InspectorHost } from '../inspector/host';
|
||||
import { viewOf } from '../inspector/objectQueries';
|
||||
@@ -101,13 +101,16 @@ export class VariablesEditor extends VStack {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
const body = new SectionStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
body.onFold = () => this.scroll.relayoutContent();
|
||||
return {
|
||||
state: this.state,
|
||||
project: () => this.state.project,
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
body,
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
foldScope: 'variables',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { shapeDrawing } from '../../core/model/vectorPath';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { SortableList, type ThumbClip, type Widget } from '../../ui';
|
||||
import { listFold } from '../sectionFolds';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
export class ShapeList extends SortableList {
|
||||
@@ -46,6 +47,7 @@ export class ShapeList extends SortableList {
|
||||
) {
|
||||
super({
|
||||
title: 'Shapes',
|
||||
...listFold('inspector', 'Shapes'),
|
||||
reversed: true,
|
||||
multiSelect: true,
|
||||
onPick: rows.onPick,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export { Widget, Box, type Size } from './Widget';
|
||||
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
|
||||
export { SectionStack } from './layout/SectionStack';
|
||||
export { SectionHeader, type SectionHeaderOptions } from './widgets/SectionHeader';
|
||||
export {
|
||||
hiddenBySections,
|
||||
SECTION_BAND_HEIGHT,
|
||||
sectionOffsets,
|
||||
type SectionRow,
|
||||
type SectionStart,
|
||||
} from './sections';
|
||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { loadEditorFonts } from './fonts';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { type StackOptions, VStack } from './Stack';
|
||||
|
||||
/**
|
||||
* A column of rows in groups (#290): a `SectionHeader` runs across the padding as a band, the rows
|
||||
* after it stand inside it, and a folded header hides its rows down to the next one. Children are
|
||||
* never stretched along the column – it is meant for a scroll container.
|
||||
*/
|
||||
export class SectionStack extends VStack {
|
||||
/** After a group was folded or unfolded – the owner measures the column again. */
|
||||
onFold: () => void = () => {};
|
||||
/** The children a fold hid, so unfolding shows only those again. */
|
||||
private readonly hiddenByFold = new WeakSet<Widget>();
|
||||
|
||||
constructor(options: StackOptions = {}) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
refold(): void {
|
||||
this.applyFolds();
|
||||
this.onFold();
|
||||
}
|
||||
|
||||
private applyFolds(): void {
|
||||
const widgets = this.children.filter((c): c is Widget => c instanceof Widget);
|
||||
const hidden = hiddenBySections(widgets.map((w) => sectionStartOf(w)?.sectionCollapsed ?? null));
|
||||
widgets.forEach((widget, i) => {
|
||||
if (hidden[i] && widget.visible) {
|
||||
widget.visible = false;
|
||||
this.hiddenByFold.add(widget);
|
||||
} else if (!hidden[i] && this.hiddenByFold.has(widget)) {
|
||||
widget.visible = true;
|
||||
this.hiddenByFold.delete(widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private rows(sizes: readonly Size[]): SectionRow[] {
|
||||
return this.items.map((item, i) => {
|
||||
const start = sectionStartOf(item);
|
||||
return {
|
||||
h: sizes[i]!.h,
|
||||
start: start !== null,
|
||||
band: start !== null && (start.sectionStart === 'band' || start.sectionCollapsed),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
this.applyFolds();
|
||||
const sizes = this.items.map((item) => item.measure());
|
||||
const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2;
|
||||
const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
|
||||
return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.applyFolds();
|
||||
const items = this.items;
|
||||
const sizes = items.map((item) => item.measure());
|
||||
const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
|
||||
items.forEach((item, i) => {
|
||||
const band = sectionStartOf(item)?.sectionStart === 'band';
|
||||
item.position.set(band ? 0 : this.padding, Math.round(ys[i]!));
|
||||
item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Groups of rows in a column (#290): a header band opens a group, and everything down to the next
|
||||
* one belongs to it. A folded group keeps its band and hides its rows.
|
||||
*/
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import { Theme } from './Theme';
|
||||
|
||||
/** Height of a band, here and at the head of a list that carries its own. */
|
||||
export const SECTION_BAND_HEIGHT = 26;
|
||||
/** Where a band's title starts, right of the mark that says whether the group is open. */
|
||||
export const SECTION_TITLE_INSET = 14;
|
||||
|
||||
/**
|
||||
* A child of a column that opens a group. `band` is laid across the column's padding, `inset`
|
||||
* stands inside it like a row and draws its band past its own edges (a list with a header).
|
||||
*/
|
||||
export interface SectionStart {
|
||||
readonly sectionStart: 'band' | 'inset' | null;
|
||||
readonly sectionCollapsed: boolean;
|
||||
}
|
||||
|
||||
export function sectionStartOf(widget: object): SectionStart | null {
|
||||
const start = widget as Partial<SectionStart>;
|
||||
return start.sectionStart ? (start as SectionStart) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which children of a column a folded group hides. Per child: `null` for a row, else whether the
|
||||
* group it opens is folded. A start is never hidden itself.
|
||||
*/
|
||||
export function hiddenBySections(starts: readonly (boolean | null)[]): boolean[] {
|
||||
let folded = false;
|
||||
return starts.map((start) => {
|
||||
if (start === null) return folded;
|
||||
folded = start;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export interface SectionRow {
|
||||
h: number;
|
||||
/** Opens a group. */
|
||||
start: boolean;
|
||||
/** Ends in a band: a header, or a list folded down to its own. */
|
||||
band: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the shown children of a column go. Bands stand flush on each other and on the top edge,
|
||||
* a group keeps the column's padding to the band after it, and rows keep the gap between them.
|
||||
*/
|
||||
export function sectionOffsets(
|
||||
rows: readonly SectionRow[],
|
||||
gap: number,
|
||||
padding: number,
|
||||
): { ys: number[]; height: number } {
|
||||
const ys: number[] = [];
|
||||
let y = rows[0]?.start ? 0 : padding;
|
||||
rows.forEach((row, i) => {
|
||||
const prev = rows[i - 1];
|
||||
// One pixel shared, so two bands show one line between them, not two.
|
||||
if (prev) y += row.start ? (prev.band ? -1 : padding) : prev.band ? gap + 2 : gap;
|
||||
ys.push(y);
|
||||
y += row.h;
|
||||
});
|
||||
const last = rows.at(-1);
|
||||
return { ys, height: y + (last && !last.band ? padding : 0) };
|
||||
}
|
||||
|
||||
/** The band itself: header colour between two lines, from `x` on. `g` is cleared by the caller. */
|
||||
export function drawSectionBand(g: Graphics, x: number, y: number, w: number, h: number): void {
|
||||
g.rect(x, y, w, h).fill(Theme.colors.panelHeader);
|
||||
g.rect(x, y, w, 1).fill(Theme.colors.border);
|
||||
g.rect(x, y + h - 1, w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
|
||||
/** The mark in front of a band's title, centred on `x`/`y`: pointing down while open, right while folded. */
|
||||
export function drawFoldMark(g: Graphics, x: number, y: number, collapsed: boolean, color: number): void {
|
||||
if (collapsed)
|
||||
g.moveTo(x - 2, y - 4)
|
||||
.lineTo(x + 3, y)
|
||||
.lineTo(x - 2, y + 4)
|
||||
.fill(color);
|
||||
else
|
||||
g.moveTo(x - 4, y - 2)
|
||||
.lineTo(x + 4, y - 2)
|
||||
.lineTo(x, y + 3)
|
||||
.fill(color);
|
||||
}
|
||||
|
||||
/** Lets the column a start stands in hide or show its group. */
|
||||
export function refoldAround(widget: { parent: object | null }): void {
|
||||
const column = widget.parent as { refold?: () => void } | null;
|
||||
column?.refold?.();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { HStack } from '../layout/Stack';
|
||||
import {
|
||||
drawFoldMark,
|
||||
drawSectionBand,
|
||||
refoldAround,
|
||||
SECTION_BAND_HEIGHT,
|
||||
SECTION_TITLE_INSET,
|
||||
type SectionStart,
|
||||
} from '../sections';
|
||||
import { Theme } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { Label } from './Label';
|
||||
|
||||
export interface SectionHeaderOptions {
|
||||
collapsed?: boolean;
|
||||
/** After a click folded or unfolded the group – for an owner that remembers it. */
|
||||
onToggle?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head of a group of rows (#290): a band across the column with the title in it, and whatever
|
||||
* `add()` puts at its right end. A click on the band folds the group; one on a control in it does not.
|
||||
*/
|
||||
export class SectionHeader extends Widget implements SectionStart {
|
||||
readonly sectionStart = 'band';
|
||||
private readonly bg = new Graphics();
|
||||
private readonly row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
private collapsed: boolean;
|
||||
|
||||
constructor(
|
||||
readonly title: string,
|
||||
private readonly options: SectionHeaderOptions = {},
|
||||
) {
|
||||
super();
|
||||
this.collapsed = options.collapsed ?? false;
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.minHeight = SECTION_BAND_HEIGHT;
|
||||
// The row takes no press itself, so one between its controls reaches the band.
|
||||
this.row.eventMode = 'passive';
|
||||
this.row.add(new Label(title, { style: 'title' }), 1);
|
||||
this.addChild(this.bg, this.row);
|
||||
this.trackHover();
|
||||
this.on('pointertap', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0 && e.target === this && !this.controlAt(e.global)) this.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts a control at the right end of the band. */
|
||||
add<T extends Widget>(widget: T): T {
|
||||
return this.row.add(widget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a control of the band stands at this point. Asked on top of the event's target: a
|
||||
* disabled control takes no press, and the press it lets through must not fold the group.
|
||||
*/
|
||||
controlAt(global: { x: number; y: number }): boolean {
|
||||
const local = this.row.toLocal(global);
|
||||
return this.row.items
|
||||
.slice(1)
|
||||
.some((item) => local.x >= item.x && local.x < item.x + item.w && local.y >= 0 && local.y < this.h);
|
||||
}
|
||||
|
||||
get sectionCollapsed(): boolean {
|
||||
return this.collapsed;
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.collapsed = !this.collapsed;
|
||||
this.options.onToggle?.(this.collapsed);
|
||||
this.invalidate();
|
||||
refoldAround(this);
|
||||
}
|
||||
|
||||
private get rowLeft(): number {
|
||||
return Theme.padding + SECTION_TITLE_INSET;
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
const row = this.row.measure();
|
||||
return { w: Math.max(this.minWidth, this.rowLeft + row.w + Theme.padding), h: SECTION_BAND_HEIGHT };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.row.position.set(this.rowLeft, 0);
|
||||
this.row.resize(Math.max(0, this.w - this.rowLeft - Theme.padding), this.h);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear();
|
||||
drawSectionBand(this.bg, 0, 0, this.w, this.h);
|
||||
const color = this.hovered ? Theme.colors.text : Theme.colors.textMuted;
|
||||
drawFoldMark(this.bg, Theme.padding + 4, this.h / 2, this.collapsed, color);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,14 @@
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { pressCount } from '../clicks';
|
||||
import {
|
||||
drawFoldMark,
|
||||
drawSectionBand,
|
||||
refoldAround,
|
||||
SECTION_BAND_HEIGHT,
|
||||
SECTION_TITLE_INSET,
|
||||
type SectionStart,
|
||||
} from '../sections';
|
||||
import { dropGap, dropMove, rowOrder } from '../sortable';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { ui } from '../UIContext';
|
||||
@@ -36,7 +44,7 @@ const DUPLICATE_SLOT = 22;
|
||||
const REMOVE_SLOT = 24;
|
||||
/** Width of the number field, where the rows have one. */
|
||||
const VALUE_WIDTH = 44;
|
||||
/** Height of the header, where there is one. */
|
||||
/** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */
|
||||
const HEADER_HEIGHT = 24;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
@@ -97,8 +105,14 @@ export interface SortableListOptions {
|
||||
/** Setting this makes a double-click on a name edit it in the row; a blank or unchanged name is not reported. */
|
||||
onRename?: (id: string, name: string) => void;
|
||||
value?: SortableValue;
|
||||
/** Stands in the header, left. */
|
||||
/** Stands in the header, left. The header is then a band, and a click on it folds the rows away (#290). */
|
||||
title?: string;
|
||||
/** How far the band reaches past both edges – the padding of the column the list stands in. */
|
||||
bleed?: number;
|
||||
/** Whether the list starts folded. */
|
||||
collapsed?: boolean;
|
||||
/** After a click folded or unfolded the list – for an owner that remembers it. */
|
||||
onFold?: (collapsed: boolean) => void;
|
||||
/** Setting this puts a plus at the right end of the header. */
|
||||
onAdd?: () => void;
|
||||
multiSelect?: boolean;
|
||||
@@ -131,8 +145,8 @@ interface RowDrag {
|
||||
/** Which part of a row a point is on. */
|
||||
type RowPart = 'row' | 'eye' | 'duplicate' | 'cross' | 'name';
|
||||
|
||||
/** What the pointer is over: a part of a row, or the plus. */
|
||||
type Hover = { row: number; part: RowPart } | 'add' | null;
|
||||
/** What the pointer is over: a part of a row, the plus, or the band beside it. */
|
||||
type Hover = { row: number; part: RowPart } | 'add' | 'head' | null;
|
||||
|
||||
/** A name edited in its row: it goes away as soon as it loses the focus. */
|
||||
class RowNameField extends TextField {
|
||||
@@ -144,7 +158,8 @@ class RowNameField extends TextField {
|
||||
}
|
||||
}
|
||||
|
||||
export class SortableList extends Widget {
|
||||
export class SortableList extends Widget implements SectionStart {
|
||||
private collapsed: boolean;
|
||||
private readonly background = new Graphics();
|
||||
private readonly marker = new Graphics();
|
||||
private readonly heading = new Text({ text: '', style: textStyle('title') });
|
||||
@@ -160,6 +175,7 @@ export class SortableList extends Widget {
|
||||
|
||||
constructor(private readonly options: SortableListOptions) {
|
||||
super();
|
||||
this.collapsed = Boolean(options.title) && (options.collapsed ?? false);
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.addChild(this.background);
|
||||
this.heading.text = (options.title ?? '').toUpperCase();
|
||||
@@ -204,7 +220,31 @@ export class SortableList extends Widget {
|
||||
|
||||
override measure(): Size {
|
||||
const rows = Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT);
|
||||
return { w: this.minWidth, h: this.headerHeight + rows };
|
||||
return { w: this.minWidth, h: this.headerHeight + (this.collapsed ? 0 : rows) };
|
||||
}
|
||||
|
||||
/** A list with a title opens a group in its column: itself, and the rows after it. */
|
||||
get sectionStart(): 'inset' | null {
|
||||
return this.options.title ? 'inset' : null;
|
||||
}
|
||||
|
||||
get sectionCollapsed(): boolean {
|
||||
return this.collapsed;
|
||||
}
|
||||
|
||||
/** A point on the band, clear of the plus (test hook); null without a title. */
|
||||
headGlobal(): { x: number; y: number } | null {
|
||||
if (!this.options.title) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + SECTION_TITLE_INSET + 20, y: origin.y + this.headerHeight / 2 };
|
||||
}
|
||||
|
||||
private toggleFold(): void {
|
||||
this.collapsed = !this.collapsed;
|
||||
this.drag = null;
|
||||
this.options.onFold?.(this.collapsed);
|
||||
this.invalidate();
|
||||
refoldAround(this);
|
||||
}
|
||||
|
||||
/** The ids as the list shows them, top first – for the check scripts. */
|
||||
@@ -276,11 +316,11 @@ export class SortableList extends Widget {
|
||||
addGlobal(): { x: number; y: number } | null {
|
||||
if (!this.options.onAdd) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + HEADER_HEIGHT / 2 };
|
||||
return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + this.headerHeight / 2 };
|
||||
}
|
||||
|
||||
private get headerHeight(): number {
|
||||
return this.options.title || this.options.onAdd ? HEADER_HEIGHT : 0;
|
||||
return this.options.title ? SECTION_BAND_HEIGHT : this.options.onAdd ? HEADER_HEIGHT : 0;
|
||||
}
|
||||
|
||||
/** Whether the rows show a cross now – with `keepOne`, not on the last item. */
|
||||
@@ -354,12 +394,14 @@ export class SortableList extends Widget {
|
||||
|
||||
private rowAt(y: number): number {
|
||||
const index = Math.floor((y - this.headerHeight) / SORTABLE_ROW_HEIGHT);
|
||||
return index >= 0 && index < this.shown.length ? index : -1;
|
||||
return !this.collapsed && index >= 0 && index < this.shown.length ? index : -1;
|
||||
}
|
||||
|
||||
private hitAt(local: { x: number; y: number }): Hover {
|
||||
if (local.y < this.headerHeight)
|
||||
return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null;
|
||||
if (local.y < this.headerHeight) {
|
||||
if (this.options.onAdd && local.x >= this.w - REMOVE_SLOT) return 'add';
|
||||
return this.options.title ? 'head' : null;
|
||||
}
|
||||
const row = this.rowAt(local.y);
|
||||
if (row < 0) return null;
|
||||
return { row, part: this.partAt(local.x) };
|
||||
@@ -384,6 +426,10 @@ export class SortableList extends Widget {
|
||||
this.options.onAdd?.();
|
||||
return;
|
||||
}
|
||||
if (hit === 'head') {
|
||||
this.toggleFold();
|
||||
return;
|
||||
}
|
||||
const id = this.shown[hit.row]!.id;
|
||||
if (hit.part === 'cross') {
|
||||
this.options.onRemove?.(id);
|
||||
@@ -475,12 +521,15 @@ export class SortableList extends Widget {
|
||||
|
||||
override redraw(): void {
|
||||
const top = this.headerHeight;
|
||||
this.background
|
||||
.clear()
|
||||
.rect(0, top, this.w, this.h - top)
|
||||
.fill(Theme.colors.control);
|
||||
this.background.clear();
|
||||
if (!this.collapsed) this.background.rect(0, top, this.w, this.h - top).fill(Theme.colors.control);
|
||||
this.marker.clear();
|
||||
this.redrawHeader();
|
||||
for (const row of this.rows) {
|
||||
row.box.visible = !this.collapsed;
|
||||
if (row.field && this.collapsed) row.field.visible = false;
|
||||
}
|
||||
if (this.collapsed) return;
|
||||
const slots = this.slots;
|
||||
const nameRight = this.nameRight;
|
||||
const eyes = Boolean(this.options.onToggleHidden);
|
||||
@@ -489,7 +538,7 @@ export class SortableList extends Widget {
|
||||
const value = this.options.value;
|
||||
const thumbs = this.options.thumb;
|
||||
const textX = this.textX;
|
||||
const hovered = this.over !== 'add' ? this.over : null;
|
||||
const hovered = this.over !== 'add' && this.over !== 'head' ? this.over : null;
|
||||
for (const [index, item] of this.shown.entries()) {
|
||||
const row = this.rows[index]!;
|
||||
const y = top + index * SORTABLE_ROW_HEIGHT;
|
||||
@@ -565,9 +614,16 @@ export class SortableList extends Widget {
|
||||
|
||||
private redrawHeader(): void {
|
||||
const top = this.headerHeight;
|
||||
const band = Boolean(this.options.title);
|
||||
this.heading.visible = top > 0;
|
||||
this.heading.position.set(0, Math.round((top - this.heading.height) / 2));
|
||||
this.heading.position.set(band ? SECTION_TITLE_INSET : 0, Math.round((top - this.heading.height) / 2));
|
||||
this.plus.clear();
|
||||
if (band) {
|
||||
const bleed = this.options.bleed ?? 0;
|
||||
drawSectionBand(this.background, -bleed, 0, this.w + bleed * 2, top);
|
||||
const color = this.over === 'head' ? Theme.colors.text : Theme.colors.textMuted;
|
||||
drawFoldMark(this.background, 4, top / 2, this.collapsed, color);
|
||||
}
|
||||
if (!this.options.onAdd) return;
|
||||
const x = this.w - REMOVE_SLOT / 2;
|
||||
const y = top / 2;
|
||||
@@ -587,7 +643,7 @@ export class SortableList extends Widget {
|
||||
}
|
||||
|
||||
function sameHover(a: Hover, b: Hover): boolean {
|
||||
if (a === null || b === null || a === 'add' || b === 'add') return a === b;
|
||||
if (a === null || b === null || typeof a === 'string' || typeof b === 'string') return a === b;
|
||||
return a.row === b.row && a.part === b.part;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Groups of rows with a band as their head (#290): which rows a folded group hides, where the bands
|
||||
* and rows of a column go, what a click on a band does, how a fold is remembered, and which groups
|
||||
* the inspector builds.
|
||||
*/
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CanvasTextMetrics } from 'pixi.js';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { buildAssetSection } from '../../src/editor/inspector/assetSection';
|
||||
import { addTitle, type InspectorHost, sectionHeader } from '../../src/editor/inspector/host';
|
||||
import { buildObjectSection } from '../../src/editor/inspector/objectSection';
|
||||
import { foldKey, isFolded, listFold, resetFolds, setFolded } from '../../src/editor/sectionFolds';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import {
|
||||
Box,
|
||||
hiddenBySections,
|
||||
Label,
|
||||
SECTION_BAND_HEIGHT,
|
||||
SectionHeader,
|
||||
sectionOffsets,
|
||||
SectionStack,
|
||||
SORTABLE_ROW_HEIGHT,
|
||||
SortableList,
|
||||
type Widget,
|
||||
} from '../../src/ui';
|
||||
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
beforeAll(() => {
|
||||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||||
});
|
||||
|
||||
const GAP = 4;
|
||||
const PADDING = 8;
|
||||
|
||||
function row(h = 24): Box {
|
||||
const box = new Box();
|
||||
box.minHeight = h;
|
||||
return box;
|
||||
}
|
||||
|
||||
function column(...children: Widget[]): SectionStack {
|
||||
const stack = new SectionStack({ gap: GAP, padding: PADDING });
|
||||
for (const child of children) stack.add(child);
|
||||
const size = stack.measure();
|
||||
stack.resize(200, size.h);
|
||||
return stack;
|
||||
}
|
||||
|
||||
function memoryStore(initial: Record<string, string> = {}) {
|
||||
const items = new Map(Object.entries(initial));
|
||||
return {
|
||||
items,
|
||||
getItem: (key: string) => items.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void items.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe('the rows a folded group hides', () => {
|
||||
it('are the ones down to the next head, never a head', () => {
|
||||
expect(hiddenBySections([false, null, null, true, null, null, false, null])).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('are none in front of the first head', () => {
|
||||
expect(hiddenBySections([null, true, null])).toEqual([false, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('where bands and rows go', () => {
|
||||
const band = { h: SECTION_BAND_HEIGHT, start: true, band: true };
|
||||
const plain = { h: 24, start: false, band: false };
|
||||
|
||||
it('puts the first band on the top edge and keeps the padding under the last row', () => {
|
||||
const { ys, height } = sectionOffsets([band, plain, plain], GAP, PADDING);
|
||||
expect(ys).toEqual([0, SECTION_BAND_HEIGHT + GAP + 2, SECTION_BAND_HEIGHT + GAP + 2 + 24 + GAP]);
|
||||
expect(height).toBe(ys[2]! + 24 + PADDING);
|
||||
});
|
||||
|
||||
it('keeps the padding between a group and the next band', () => {
|
||||
const { ys } = sectionOffsets([band, plain, band], GAP, PADDING);
|
||||
expect(ys[2]! - (ys[1]! + 24)).toBe(PADDING);
|
||||
});
|
||||
|
||||
it('stands folded bands on each other with one line between them', () => {
|
||||
const { ys, height } = sectionOffsets([band, band, band], GAP, PADDING);
|
||||
expect(ys).toEqual([0, SECTION_BAND_HEIGHT - 1, 2 * (SECTION_BAND_HEIGHT - 1)]);
|
||||
expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT);
|
||||
});
|
||||
|
||||
it('starts inside the padding without a band on top', () => {
|
||||
expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a column of groups', () => {
|
||||
it('runs a band across the padding and keeps the rows inside it', () => {
|
||||
const head = new SectionHeader('POSITION');
|
||||
const first = row();
|
||||
const stack = column(head, first);
|
||||
expect([head.x, head.w, head.h]).toEqual([0, 200, SECTION_BAND_HEIGHT]);
|
||||
expect([first.x, first.w]).toEqual([PADDING, 200 - 2 * PADDING]);
|
||||
expect(first.y).toBe(SECTION_BAND_HEIGHT + GAP + 2);
|
||||
expect(stack.measure().h).toBe(first.y + 24 + PADDING);
|
||||
});
|
||||
|
||||
it('hides the rows of a folded group, takes their room and gives both back', () => {
|
||||
const a = new SectionHeader('A');
|
||||
const b = new SectionHeader('B');
|
||||
const rows = [row(), row(), row()];
|
||||
const stack = column(a, rows[0]!, rows[1]!, b, rows[2]!);
|
||||
const open = stack.measure().h;
|
||||
let told = 0;
|
||||
stack.onFold = () => (told += 1);
|
||||
|
||||
a.toggle();
|
||||
expect(rows.map((r) => r.visible)).toEqual([false, false, true]);
|
||||
expect(stack.measure().h).toBe(open - 2 * 24 - 2 * GAP - 2 - PADDING - 1);
|
||||
expect(told).toBe(1);
|
||||
stack.layout();
|
||||
expect(b.y).toBe(SECTION_BAND_HEIGHT - 1);
|
||||
|
||||
a.toggle();
|
||||
expect(rows.map((r) => r.visible)).toEqual([true, true, true]);
|
||||
expect(stack.measure().h).toBe(open);
|
||||
});
|
||||
|
||||
it('leaves a row hidden that was hidden for its own reason', () => {
|
||||
const head = new SectionHeader('A');
|
||||
const own = row();
|
||||
own.visible = false;
|
||||
const other = row();
|
||||
column(head, own, other);
|
||||
head.toggle();
|
||||
head.toggle();
|
||||
expect([own.visible, other.visible]).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('starts with a group folded that is built folded', () => {
|
||||
const rows = [row()];
|
||||
column(new SectionHeader('A', { collapsed: true }), rows[0]!);
|
||||
expect(rows[0]!.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a band', () => {
|
||||
it('holds its title and puts a control at its right end, inside it', () => {
|
||||
const head = new SectionHeader('VARIABLES');
|
||||
const control = head.add(row());
|
||||
control.minWidth = 40;
|
||||
column(head);
|
||||
const texts = head.children.flatMap((c) => c.children).filter((c): c is Label => c instanceof Label);
|
||||
expect(texts.map((label) => label.text)).toEqual(['VARIABLES']);
|
||||
const right = control.parent!.x + control.x + control.w;
|
||||
expect(right).toBe(200 - PADDING);
|
||||
expect(control.w).toBe(40);
|
||||
});
|
||||
|
||||
it('knows where its controls stand, so a press a disabled one lets through folds nothing', () => {
|
||||
const head = new SectionHeader('PROPERTIES');
|
||||
const control = head.add(row());
|
||||
control.minWidth = 40;
|
||||
control.setEnabled(false);
|
||||
column(head);
|
||||
const y = SECTION_BAND_HEIGHT / 2;
|
||||
expect(head.controlAt({ x: 200 - PADDING - 20, y })).toBe(true);
|
||||
expect(head.controlAt({ x: 200 - PADDING - 41, y })).toBe(false);
|
||||
expect(head.controlAt({ x: 60, y })).toBe(false);
|
||||
});
|
||||
|
||||
it('tells its owner each time it is folded or unfolded', () => {
|
||||
const told: boolean[] = [];
|
||||
const head = new SectionHeader('A', { onToggle: (on) => told.push(on) });
|
||||
head.toggle();
|
||||
head.toggle();
|
||||
expect(told).toEqual([true, false]);
|
||||
expect(head.sectionCollapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a list with a title', () => {
|
||||
const options = { onPick: () => {}, onMove: () => {} };
|
||||
|
||||
it('folds down to its band and opens a group in its column', () => {
|
||||
const told: boolean[] = [];
|
||||
const list = new SortableList({
|
||||
...options,
|
||||
title: 'Layers',
|
||||
collapsed: true,
|
||||
onFold: (on) => told.push(on),
|
||||
});
|
||||
list.setItems(
|
||||
[
|
||||
{ id: 'a', label: 'A' },
|
||||
{ id: 'b', label: 'B' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
const note = row();
|
||||
column(list, note);
|
||||
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT);
|
||||
expect(list.sectionStart).toBe('inset');
|
||||
expect(note.visible).toBe(false);
|
||||
expect(list.x).toBe(PADDING);
|
||||
});
|
||||
|
||||
it('shows its rows while it is open', () => {
|
||||
const list = new SortableList({ ...options, title: 'Layers' });
|
||||
list.setItems([{ id: 'a', label: 'A' }], []);
|
||||
expect(list.measure().h).toBe(SECTION_BAND_HEIGHT + SORTABLE_ROW_HEIGHT);
|
||||
});
|
||||
|
||||
it('is no group without a title, and cannot start folded', () => {
|
||||
const list = new SortableList({ ...options, collapsed: true });
|
||||
expect(list.sectionStart).toBeNull();
|
||||
expect(list.sectionCollapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the memory of folded groups', () => {
|
||||
beforeEach(() => resetFolds());
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetFolds();
|
||||
});
|
||||
|
||||
it('names a group by panel and title, whatever the title counts', () => {
|
||||
expect(foldKey('inspector', 'KEY AT FRAME 12')).toBe(foldKey('inspector', 'KEY AT FRAME 3'));
|
||||
expect(foldKey('inspector', '2 OBJECTS SELECTED')).toBe(foldKey('inspector', '14 OBJECTS SELECTED'));
|
||||
expect(foldKey('inspector', 'LAYERS')).not.toBe(foldKey('tilemap', 'LAYERS'));
|
||||
expect(foldKey('inspector', 'POSITION')).not.toBe(foldKey('inspector', 'TRANSFORM'));
|
||||
});
|
||||
|
||||
it('writes a fold to the browser and reads it back on a new page', () => {
|
||||
const store = memoryStore();
|
||||
vi.stubGlobal('window', { localStorage: store });
|
||||
const key = foldKey('inspector', 'TRANSFORM');
|
||||
expect(isFolded(key)).toBe(false);
|
||||
setFolded(key, true);
|
||||
expect(isFolded(key)).toBe(true);
|
||||
resetFolds();
|
||||
expect(isFolded(key)).toBe(true);
|
||||
setFolded(key, false);
|
||||
resetFolds();
|
||||
expect(isFolded(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('remembers for the page where the browser gives no storage, and reads rubbish as nothing', () => {
|
||||
const key = foldKey('inspector', 'TRANSFORM');
|
||||
setFolded(key, true);
|
||||
expect(isFolded(key)).toBe(true);
|
||||
resetFolds();
|
||||
vi.stubGlobal('window', { localStorage: memoryStore({ 'game-maker.folded-sections': '{broken' }) });
|
||||
expect(isFolded(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a band folded that was folded before, and remembers a click on it', () => {
|
||||
const store = memoryStore();
|
||||
vi.stubGlobal('window', { localStorage: store });
|
||||
const host = makeHost();
|
||||
setFolded(foldKey('inspector', 'TRANSFORM'), true);
|
||||
expect(sectionHeader(host, 'TRANSFORM').sectionCollapsed).toBe(true);
|
||||
const position = sectionHeader(host, 'POSITION');
|
||||
expect(position.sectionCollapsed).toBe(false);
|
||||
position.toggle();
|
||||
expect(isFolded(foldKey('inspector', 'POSITION'))).toBe(true);
|
||||
// Another panel's group of the same name is its own.
|
||||
expect(sectionHeader({ ...host, foldScope: 'tilemap' }, 'TRANSFORM').sectionCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a list its fold under the title in capitals', () => {
|
||||
vi.stubGlobal('window', { localStorage: memoryStore() });
|
||||
listFold('pixelart', 'Layers').onFold(true);
|
||||
expect(isFolded(foldKey('pixelart', 'LAYERS'))).toBe(true);
|
||||
expect(listFold('pixelart', 'Layers').collapsed).toBe(true);
|
||||
expect(listFold('pixelart', 'Frames').collapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function makeHost(state = new EditorState()): InspectorHost {
|
||||
return {
|
||||
state,
|
||||
body: new SectionStack({ gap: GAP, padding: PADDING }),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
project: () => state.project,
|
||||
liveVariables: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the body shows, top to bottom: a band by its title in brackets, a row by the label it starts with. */
|
||||
function outline(host: InspectorHost): string[] {
|
||||
const firstLabel = (node: Widget): string => {
|
||||
if (node instanceof Label) return node.text;
|
||||
for (const child of node.children) {
|
||||
const found = firstLabel(child as Widget);
|
||||
if (found) return found;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return (host.body.children as Widget[]).map((child) =>
|
||||
child instanceof SectionHeader ? `[${child.title}]` : firstLabel(child),
|
||||
);
|
||||
}
|
||||
|
||||
describe('the groups of the inspector', () => {
|
||||
beforeEach(() => resetFolds());
|
||||
|
||||
function fixture() {
|
||||
const f = makeSpriteScene();
|
||||
const state = new EditorState();
|
||||
state.loadProject(f.project, 1);
|
||||
return { f, state };
|
||||
}
|
||||
|
||||
it('gives the switches of a placed object a group of their own, under its head', () => {
|
||||
const { f, state } = fixture();
|
||||
const objectId = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const host = makeHost(state);
|
||||
buildObjectSection(host, f.sceneId, objectId);
|
||||
const shown = outline(host);
|
||||
expect(shown.slice(0, 7)).toEqual([
|
||||
'[SPRITE OBJECT]',
|
||||
'Name',
|
||||
'Asset',
|
||||
'Parent',
|
||||
'[SWITCHES]',
|
||||
'Enabled',
|
||||
'Keep on scene load',
|
||||
]);
|
||||
expect(shown[7]).toBe('[POSITION]');
|
||||
expect(shown.filter((line) => line.startsWith('['))).toEqual([
|
||||
'[SPRITE OBJECT]',
|
||||
'[SWITCHES]',
|
||||
'[POSITION]',
|
||||
'[TRANSFORM]',
|
||||
'[EDITOR OFFSET (SCENE VIEW ONLY)]',
|
||||
'[PROPERTIES]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts the switches of a behavior there too, and its Open button into the band of its variables', () => {
|
||||
const { f, state } = fixture();
|
||||
const behavior = makeBehavior(f, 'Mover', ['speed']);
|
||||
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
||||
const host = makeHost(state);
|
||||
buildObjectSection(host, f.sceneId, objectId);
|
||||
const shown = outline(host);
|
||||
const from = shown.indexOf('[SWITCHES]');
|
||||
const to = shown.indexOf('[POSITION]');
|
||||
expect(shown.slice(from + 1, to)).toEqual([
|
||||
'Enabled',
|
||||
'Keep on scene load',
|
||||
'Run in editor',
|
||||
'Run while paused',
|
||||
]);
|
||||
const variables = host.body.children.find(
|
||||
(child): child is SectionHeader => child instanceof SectionHeader && child.title === 'VARIABLES',
|
||||
)!;
|
||||
const texts: string[] = [];
|
||||
const walk = (node: Widget): void => {
|
||||
if ('text' in node && typeof node.text === 'string') texts.push(node.text);
|
||||
for (const child of node.children) walk(child as Widget);
|
||||
};
|
||||
walk(variables);
|
||||
expect(texts).toContain('Open');
|
||||
});
|
||||
|
||||
it('splits the rows of an asset into switches and properties', () => {
|
||||
const { f, state } = fixture();
|
||||
const host = makeHost(state);
|
||||
buildAssetSection(host, f.spriteId);
|
||||
const shown = outline(host);
|
||||
expect(shown.slice(0, 5)).toEqual([
|
||||
'[SPRITE ASSET]',
|
||||
'Name',
|
||||
'[SWITCHES]',
|
||||
'Enabled',
|
||||
'Keep on scene load',
|
||||
]);
|
||||
expect(shown[5]).toBe('[PROPERTIES]');
|
||||
expect(shown.length).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it('heads the rows of a behavior asset as switches, with no empty group after them', () => {
|
||||
const { f, state } = fixture();
|
||||
const behavior = makeBehavior(f, 'Mover', ['speed']);
|
||||
const host = makeHost(state);
|
||||
buildAssetSection(host, behavior.id);
|
||||
expect(findAsset(f.project, behavior.id)?.type).toBe('behavior');
|
||||
expect(outline(host).filter((line) => line.startsWith('['))).toEqual([
|
||||
'[BEHAVIOR ASSET]',
|
||||
'[SWITCHES]',
|
||||
'[VARIABLES]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds a title as a band, in any body', () => {
|
||||
const host = makeHost();
|
||||
addTitle(host, 'CONTENT');
|
||||
expect(host.body.children[0]).toBeInstanceOf(SectionHeader);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,84 @@ export async function inspector(t) {
|
||||
);
|
||||
await shot('02-object-selected');
|
||||
|
||||
// 2a-ii. Groups with a band as their head (#290). The band runs from edge to edge of the panel in
|
||||
// the header colour, the rows under it stand on the panel's own; the switches have a group.
|
||||
const section = (title) => json(`__gm.section(${JSON.stringify(title)})`);
|
||||
const panel = await json(`__gm.inspectorRect()`);
|
||||
let transform = await section('TRANSFORM');
|
||||
const switches = await section('SWITCHES');
|
||||
const position = await section('POSITION');
|
||||
const enabledRow = await json(`__gm.inspectorField('Enabled')`);
|
||||
const colors = transform
|
||||
? await json(
|
||||
`[__gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.y)}),` +
|
||||
` __gm.pixelAt(${Math.round(transform.rect.x + transform.rect.w - 6)}, ${Math.round(transform.y)}),` +
|
||||
` __gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.rect.y + transform.rect.h + 12)}),` +
|
||||
` __gm.themeColor('panelHeader'), __gm.themeColor('panel')]`,
|
||||
)
|
||||
: [];
|
||||
check(
|
||||
'a group head is a band from edge to edge, in a colour the rows under it do not have',
|
||||
transform &&
|
||||
transform.rect.x === panel.x &&
|
||||
panel.w - transform.rect.w <= 4 &&
|
||||
colors[0] === colors[3] &&
|
||||
colors[1] === colors[3] &&
|
||||
colors[2] === colors[4] &&
|
||||
colors[3] !== colors[4],
|
||||
JSON.stringify({ panel, rect: transform?.rect, colors }),
|
||||
);
|
||||
check(
|
||||
'the switches of an object stand in a group of their own, above its position',
|
||||
switches && position && enabledRow && switches.y < enabledRow.y && enabledRow.y < position.y,
|
||||
JSON.stringify({ switches: switches?.y, enabled: enabledRow?.y, position: position?.y }),
|
||||
);
|
||||
// A click on the band folds the group: its rows go, and the next band moves up against it.
|
||||
const folds = () => json(`localStorage.getItem('game-maker.folded-sections')`);
|
||||
await click(transform.x, transform.y);
|
||||
await settle();
|
||||
transform = await section('TRANSFORM');
|
||||
let offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)');
|
||||
const folded = {
|
||||
collapsed: transform.collapsed,
|
||||
rotation: await json(`__gm.inspectorField('Rotation')`),
|
||||
gap: offset.rect.y - (transform.rect.y + transform.rect.h),
|
||||
stored: await folds(),
|
||||
};
|
||||
await shot('02-section-folded');
|
||||
// Folded once, the group is folded on the next object too.
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1'] })`,
|
||||
);
|
||||
await settle();
|
||||
const onNext = (await section('TRANSFORM'))?.collapsed;
|
||||
await clickText('Blue crate');
|
||||
transform = await section('TRANSFORM');
|
||||
await click(transform.x, transform.y);
|
||||
await settle();
|
||||
transform = await section('TRANSFORM');
|
||||
offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)');
|
||||
const rotationBack = await json(`__gm.inspectorField('Rotation')`);
|
||||
check(
|
||||
'a click on a band folds its group away, for every object, and another click brings it back',
|
||||
folded.collapsed === true &&
|
||||
folded.rotation === null &&
|
||||
folded.gap === -1 &&
|
||||
String(folded.stored).includes('inspector:TRANSFORM') &&
|
||||
onNext === true &&
|
||||
transform.collapsed === false &&
|
||||
rotationBack !== null &&
|
||||
offset.rect.y > rotationBack.y &&
|
||||
!String(await folds()).includes('inspector:TRANSFORM'),
|
||||
JSON.stringify({
|
||||
folded,
|
||||
onNext: onNext ?? null,
|
||||
open: transform.collapsed,
|
||||
rotationBack,
|
||||
stored: await folds(),
|
||||
}),
|
||||
);
|
||||
|
||||
// 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
|
||||
// crate overrides width and color, the camera and the red crate's subtree nothing.
|
||||
const GOLD = '#f2c84b';
|
||||
|
||||
@@ -223,6 +223,10 @@ export async function shader(t) {
|
||||
check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' '));
|
||||
// The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and
|
||||
// Texture say it above). The label itself is no anchor: the tab button says "Shader" too.
|
||||
// With its groups under bands (#290) the inspector is taller than the window: scrolled to its end.
|
||||
const panelRect = await json('__gm.inspectorRect()');
|
||||
await t.wheel(panelRect.x + 40, panelRect.y + panelRect.h / 2, 2000);
|
||||
await t.settle();
|
||||
const shaderField = await locate('None', true);
|
||||
check(
|
||||
'a sprite has a Shader row reading None, and no shader brings no parameter rows',
|
||||
|
||||
@@ -741,5 +741,35 @@ export async function pixelArt(t) {
|
||||
(await json('__gm.pixelArt.current()')) === id && colourDistance(back, HEX[RED]) < 10,
|
||||
JSON.stringify({ current: await json('__gm.pixelArt.current()'), back }),
|
||||
);
|
||||
// 48n. The header of a list is a band like every group head (#290): a click on it, clear of the
|
||||
// plus, folds the rows and what stands under them away; another click brings them back.
|
||||
const layerHead = await control('layer:head');
|
||||
const mergeBefore = await control('layer:merge');
|
||||
await click(layerHead.x, layerHead.y);
|
||||
await settle();
|
||||
const foldedHead = await control('layer:head');
|
||||
const foldedList = {
|
||||
folded: foldedHead.folded,
|
||||
h: foldedHead.h,
|
||||
merge: await control('layer:merge'),
|
||||
stored: await json(`localStorage.getItem('game-maker.folded-sections')`),
|
||||
};
|
||||
await shot('48-list-folded');
|
||||
await click(foldedHead.x, foldedHead.y);
|
||||
await settle();
|
||||
const openHead = await control('layer:head');
|
||||
check(
|
||||
'a click on a list header folds its rows and the button under them away, another brings them back',
|
||||
Boolean(mergeBefore) &&
|
||||
layerHead.folded === false &&
|
||||
foldedList.folded === true &&
|
||||
foldedList.h <= layerHead.h - 30 &&
|
||||
foldedList.merge === null &&
|
||||
String(foldedList.stored).includes('pixelart:LAYERS') &&
|
||||
openHead.folded === false &&
|
||||
openHead.h === layerHead.h &&
|
||||
(await control('layer:merge')) !== null,
|
||||
JSON.stringify({ foldedList, open: openHead.folded, h: [layerHead.h, openHead.h] }),
|
||||
);
|
||||
await showTab('scene');
|
||||
}
|
||||
Reference in new issue
Block a user