diff --git a/README.md b/README.md index 752fdecd..9b8741f1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/debug/pixelArtHooks.ts b/src/editor/debug/pixelArtHooks.ts index 646e8a7c..5ff32849 100644 --- a/src/editor/debug/pixelArtHooks.ts +++ b/src/editor/debug/pixelArtHooks.ts @@ -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; diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index ab17265b..4259dcd4 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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. */ diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index f651f199..6c8c2b9f 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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, }; diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 79dec838..5309d786 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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); diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index be088c9a..1e65311b 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -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; /** 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( diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 6a92140c..d6d75bef 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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'); diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 7717e0e4..ff356830 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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; diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 23c1ba71..cdada0a9 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -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 }), diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index a65a1ca3..792ba519 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -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(); 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 { diff --git a/src/editor/sectionFolds.ts b/src/editor/sectionFolds.ts new file mode 100644 index 00000000..9d7c64b2 --- /dev/null +++ b/src/editor/sectionFolds.ts @@ -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 | null = null; + +function storage(): Pick | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +function read(): Set { + 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; +} diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index 3174481a..f4941e2b 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -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', }; } diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index fc56ea23..389f7d7f 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -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, diff --git a/src/ui/index.ts b/src/ui/index.ts index 5410971c..ed98f916 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/layout/SectionStack.ts b/src/ui/layout/SectionStack.ts new file mode 100644 index 00000000..f25db288 --- /dev/null +++ b/src/ui/layout/SectionStack.ts @@ -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(); + + 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)); + }); + } +} diff --git a/src/ui/sections.ts b/src/ui/sections.ts new file mode 100644 index 00000000..f5ef8504 --- /dev/null +++ b/src/ui/sections.ts @@ -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; + 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?.(); +} diff --git a/src/ui/widgets/SectionHeader.ts b/src/ui/widgets/SectionHeader.ts new file mode 100644 index 00000000..9cc631f8 --- /dev/null +++ b/src/ui/widgets/SectionHeader.ts @@ -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(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); + } +} diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 73761c3c..9b1c370f 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -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; } diff --git a/tests/editor/sections.test.ts b/tests/editor/sections.test.ts new file mode 100644 index 00000000..766b7913 --- /dev/null +++ b/tests/editor/sections.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 9fb70282..9145b17b 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -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'; diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 34227be7..852e438a 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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', diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 2fecfdb3..d9da7c35 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -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'); }