diff --git a/README.md b/README.md index eaeacedc..4d52923a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,10 @@ shown on the node and editable in the inspector. **Editor symbols are always on top.** The viewport draws scene content (sprites) in an `objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a -sprite can never hide a symbol regardless of z or hierarchy order. +sprite can never hide a symbol regardless of z or hierarchy order. A viewport label keeps a +constant size on screen however far you zoom, and both kinds of them – the object names of the +Scene tab and the frame names of the Texture tab – read that size from one token +(`Theme.viewportLabelSize`); the two `viewportLabel` styles differ in colour only. **Z-order is part of the position.** `position.z` is the draw order of a scene object: higher `z` is drawn on top; objects with equal `z` keep their hierarchy (tree) order. It is edited in the @@ -120,6 +123,11 @@ subtree back. Re-parenting keeps the world position by recomputing the local one are rejected. In the **Scene objects** panel children are indented below their parent; drag a row onto another row to parent it, onto the empty space below the rows (or right-click → Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu. +Dragging into the **gap** above or below a row – its outer quarter, marked with a line instead of +an outline – orders the object among *that* row's siblings rather than putting it inside, and +re-parents it on the way when the gap belongs to another parent. Since equal `z` falls back to +this order, that is also how two objects at the same depth are put in front of each other. A drag +that lands where it started changes nothing and costs no undo step. Rows with children **fold**: click the chevron (or press ←/→, or double-click the row) to open and close a subtree, right-click → **Expand all** / **Collapse all** for the whole scene. A scene opens fully collapsed – which rows are open is view state of the panel, never stored in @@ -225,13 +233,17 @@ plain colour and comes back by itself on undo. Deleting the whole texture counts wearing *any* of its frames, not just the ones naming the texture directly. **Fitting the sprite to the picture.** The *Width* and *Height* rows of a sprite carry a ↔ / ↕ -button that sets that one side from the other and the picture's proportions — the frame's -rectangle when it wears a frame, the whole image otherwise. Like ↺ and ↑ the buttons exist only -when they would do something: no picture, an image not yet measured, or a sprite already in -proportion means no button at all. Fitting is an ordinary property write, so on an object it -becomes an override and it is one undo step. The comparison has a relative tolerance rather than -being exact — of the 40 000 picture sizes up to 200 × 200, 3135 do not survive the round trip -through a division, and an exact test would leave the button standing after its own click. +button offering two things: **Fit to 64 × 48** sets that one side from the other and the picture's +proportions — the frame's rectangle when it wears a frame, the whole image otherwise — and **Set +to 64 × 48 px** puts both sides at the picture's own pixel size. Like ↺ and ↑ the button exists +only when it would do something, and follows the same rule for how many: no offer means no button, +one is a direct click, both make it the menu form ↔ ▾. So a sprite without a picture, or one +already in proportion *and* at pixel size, carries nothing. Either action is an ordinary property +write, so on an object it becomes an override, and setting both sides is still one undo step. The +proportions are compared with a relative tolerance rather than exactly — of the 40 000 picture +sizes up to 200 × 200, 3135 do not survive the round trip through a division, and an exact test +would leave the button standing after its own click. The pixel size is compared exactly, because +those two numbers are written as they are. Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is @@ -346,7 +358,7 @@ selected and switched back on. ``` web/index.html Vite source page (dev) index.html built page (generated, served) -src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template) +src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), container (the cycle guard) src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes//.ts @@ -362,6 +374,7 @@ src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) +src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths) api/ PHP backend data/projects/ persisted projects tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ ``` @@ -390,8 +403,8 @@ npm run lint # ESLint, type-aware; --fix for the fixable half npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html -node tools/seed-e2e.mjs && node tools/e2e.mjs # 140 editor checks, screenshots in /tmp/gm-e2e-*.png -node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end +node tools/seed-e2e.mjs && node tools/e2e.mjs # 258 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end ``` **Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is @@ -459,9 +472,8 @@ object in the selection (hierarchy rows too). ## Not yet -Reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers +Several scenes in one run (a `Load scene` node – screens are containers today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to -the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the -proportions, but nothing sets a sprite to the picture's pixel size outright. Slicing cuts a -regular grid only — no detection of where the sprites actually +the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no +nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually sit on the sheet. Unreferenced image files are never collected. diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 9a96d64b..3100d13a 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -208,6 +208,50 @@ export class ClearOverrideCommand extends OverrideCommand { * Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed * for the new parent. */ +/** + * Moves an object among its siblings. Sibling order is the order the stored list happens to have + * (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that + * order, it is also how two overlapping objects are put in front of each other. + */ +export class ReorderObjectCommand implements Command { + readonly label = 'Reorder object'; + private previousIndex = -1; + + /** `beforeId` empty means "behind its last sibling". */ + constructor( + private readonly holderId: string, + private readonly objectId: string, + private readonly beforeId: string, + ) {} + + execute(project: Project): void { + const holder = requireHolder(project, this.holderId); + requireStructural(project, holder, this.objectId); + const object = requireObject(project, this.holderId, this.objectId); + if (this.beforeId === this.objectId) + throw new CommandError('An object cannot be moved in front of itself'); + let at = holder.objects.length; + if (this.beforeId) { + requireStructural(project, holder, this.beforeId); + const before = requireObject(project, this.holderId, this.beforeId); + if (before.parentId !== object.parentId) + throw new CommandError('An object can only be reordered among its own siblings'); + at = holder.objects.indexOf(before); + } + this.previousIndex = holder.objects.indexOf(object); + holder.objects.splice(this.previousIndex, 1); + // The index was read before the removal, so anything behind it has moved up by one. + holder.objects.splice(at > this.previousIndex ? at - 1 : at, 0, object); + } + + undo(project: Project): void { + const holder = requireHolder(project, this.holderId); + const object = requireObject(project, this.holderId, this.objectId); + holder.objects.splice(holder.objects.indexOf(object), 1); + holder.objects.splice(this.previousIndex, 0, object); + } +} + export class SetParentCommand implements Command { readonly label = 'Set parent'; private previous: { parentId: string; position: Position } = { diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 9c9c6089..44c0c97b 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -19,6 +19,11 @@ function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] { return tree.objects.filter((o) => o.parentId === parentId); } +/** Children of `parentId` (`''` = top level) in sibling order – what a reorder rearranges. */ +export function childIdsOf(tree: ObjectTree, parentId: string): string[] { + return childrenOf(tree, parentId).map((o) => o.id); +} + function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined { return object.parentId ? findObject(tree, object.parentId) : undefined; } diff --git a/src/core/properties/aspect.ts b/src/core/properties/aspect.ts index 22382767..e3aaecdc 100644 --- a/src/core/properties/aspect.ts +++ b/src/core/properties/aspect.ts @@ -33,6 +33,15 @@ export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeA return Number.isFinite(fitted) && fitted > 0 ? fitted : null; } +/** + * Whether the sprite is drawn at the picture's own pixel size – one image pixel per world unit. + * Exact, unlike `matchesAspect`: these two numbers are written as they are, with no division in + * between that could cost a digit. + */ +export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean { + return Number(props.width) === size.w && Number(props.height) === size.h; +} + /** * Whether the sprite already has the picture's proportions. Compared as a cross product with a * relative tolerance, because fitting one side and then asking about the other must not answer diff --git a/src/editor/inspector/aspectAction.ts b/src/editor/inspector/aspectAction.ts index 4c382fd8..0e5b2954 100644 --- a/src/editor/inspector/aspectAction.ts +++ b/src/editor/inspector/aspectAction.ts @@ -1,29 +1,58 @@ +import type { Command } from '../../core/commands/Command'; +import { CompoundCommand } from '../../core/commands/CompoundCommand'; import type { Asset } from '../../core/model/Asset'; import type { PropertyBag } from '../../core/model/Properties'; import { fitAxisToAspect, matchesAspect, + matchesPixelSize, + type PixelSize, type SizeAxis, texturePixelSize, } from '../../core/properties/aspect'; import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; -import type { FieldActionGroup } from './fieldFactory'; +import type { FieldAction, FieldActionGroup } from './fieldFactory'; import type { InspectorHost } from './host'; /** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */ const AXIS_GLYPH: Record = { width: '↔', height: '↕' }; +/** What a size action writes: one side for a fit, both for the picture's pixel size. */ +export type SizeValues = Partial>; + +/** One undo step whether the action wrote one side or both. */ +export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command { + const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value)); + return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands); +} + +/** The pixel size as the schema would store it – null where a min/max would change a side. */ +function storablePixelSize(asset: Asset, size: PixelSize): SizeValues | null { + const schema = schemaForAsset(asset); + const values: SizeValues = {}; + for (const axis of ['width', 'height'] as const) { + const def = schema.find((d) => d.key === axis); + if (!def) return null; + const wanted = axis === 'width' ? size.w : size.h; + const next = Number(coerceValue(def, wanted)); + if (next !== wanted) return null; + values[axis] = next; + } + return values; +} + /** - * "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also - * wears a texture. Empty for every other row, and – through an action list that comes back - * empty – while no texture or frame is assigned, so the button is not drawn at all. + * The size actions of the Width/Height rows of an asset that also wears a texture: fit this side + * to the picture's proportions, or set both sides to its pixel size. Empty for every other row, + * and – through an action list that comes back empty – while no texture or frame is assigned, so + * the button is not drawn at all. */ export function aspectActions( host: InspectorHost, asset: Asset, def: PropertyDef, props: () => PropertyBag | null, - commit: (value: number) => void, + commit: (values: SizeValues) => void, ): FieldActionGroup[] { if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return []; // A size without a picture (collider, camera) has no proportions to fit to. @@ -35,14 +64,23 @@ export function aspectActions( available: () => { const bag = props(); const size = bag && texturePixelSize(host.project(), bag); - if (!bag || !size || matchesAspect(size, bag)) return []; - const fitted = fitAxisToAspect(size, bag, axis); - if (fitted === null) return []; + if (!bag || !size) return []; + const actions: FieldAction[] = []; + const fitted = matchesAspect(size, bag) ? null : fitAxisToAspect(size, bag, axis); // Offered only when the schema would store it unchanged: a value its min/max clamps // cannot make the sprite proportional, so the button would never go away again. - const next = Number(coerceValue(def, fitted)); - if (next !== fitted) return []; - return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }]; + const next = fitted === null ? null : Number(coerceValue(def, fitted)); + if (next !== null && next === fitted) { + actions.push({ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit({ [axis]: next }) }); + } + const pixels = matchesPixelSize(size, bag) ? null : storablePixelSize(asset, size); + if (pixels) { + actions.push({ + label: `Set to ${size.w} × ${size.h} px`, + onSelect: () => commit(pixels), + }); + } + return actions; }, }, ]; diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index e9de7306..b4e4f738 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -20,7 +20,7 @@ import { } from '../../core/properties/PropertySchema'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; -import { aspectActions } from './aspectAction'; +import { aspectActions, sizeCommand } from './aspectAction'; import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; @@ -101,7 +101,10 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property const target = findAsset(host.state.project, asset.id); return target ? resolveAssetProps(target) : null; }, - (value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)), + (values) => + host.state.execute( + sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)), + ), ), reset: () => { const target = findAsset(host.state.project, asset.id); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 83ccc0be..a8c6a508 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -56,7 +56,7 @@ export interface FieldRow { const FIELD_LABEL_WIDTH = 64; /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ -interface FieldAction { +export interface FieldAction { label: string; onSelect: () => void; } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 81f68349..8e3b1e80 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -42,7 +42,7 @@ import { } from '../../core/properties/resolve'; import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; -import { aspectActions } from './aspectAction'; +import { aspectActions, sizeCommand } from './aspectAction'; import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; @@ -379,7 +379,10 @@ function propertyRow( owner, def, () => currentProps(host.state, holderId, objectId), - (value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)), + (values) => + host.state.execute( + sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)), + ), ), reset: () => { const { template, asset } = bases(); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 3836e280..a62bacdc 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,19 +1,22 @@ +import type { Command } from '../../core/commands/Command'; +import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; -import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; +import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { hasColorSwatch } from '../../core/model/AssetTypes'; -import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; +import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { resolveSceneObjectProps } from '../../core/properties/resolve'; -import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; +import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { addCollider, createContainer, removeObjects } from '../actions'; import { type EditorState, objectSelection } from '../state'; /** * Objects of the scene or container being edited, as a tree (children indented below their parent). * Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto - * another to parent it there; drop it on the empty space below the rows to move it to the - * top level. The object keeps its world position either way. + * another to parent it there, into the gap above or below a row to order it among that row's + * siblings, or onto the empty space below the rows to move it to the top level. The object keeps + * its world position every time. * * Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them. * Everything starts collapsed – which ids are open is view state kept here, never part of the @@ -42,14 +45,7 @@ export class HierarchyPanel extends Panel { if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id); else state.select(id ? objectSelection(holder.id, [id]) : null); }; - this.list.onDrop = (draggedId, targetId) => { - const holder = this.holder(); - const object = holder && findObject(holder, draggedId); - if (!holder || !object || object.parentId === (targetId ?? '')) return; - // Unfold the new parent, otherwise the dragged row would seem to vanish on drop. - if (targetId) this.expanded.add(targetId); - state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? '')); - }; + this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot); this.list.onToggle = (id, expand) => this.setExpanded(id, expand); this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); @@ -72,6 +68,45 @@ export class HierarchyPanel extends Panel { this.refresh(); } + /** + * A drop either puts the dragged row inside another one (parenting, which keeps the world + * position) or into a gap, which orders it among the siblings there – and does both when the + * gap belongs to a parent the object did not have. A move that would leave the order as it is + * does nothing, so a drag that lands where it started costs no undo step. + */ + private drop(draggedId: string, spot: DropSpot): void { + const holder = this.holder(); + const object = holder && findObject(holder, draggedId); + if (!holder || !object) return; + if (spot.at === 'onto') { + if (object.parentId === spot.targetId) return; + // Unfold the new parent, otherwise the dragged row would seem to vanish on drop. + this.expanded.add(spot.targetId); + this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId)); + return; + } + let parentId = ''; + let beforeId = ''; + if (spot.at !== 'end') { + const target = findObject(holder, spot.targetId); + if (!target) return; + parentId = target.parentId; + const order = childIdsOf(holder, parentId); + beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? ''); + } + if (beforeId === draggedId) return; + const siblings = childIdsOf(holder, parentId); + const rest = siblings.filter((id) => id !== draggedId); + const at = beforeId ? rest.indexOf(beforeId) : rest.length; + const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)]; + if (object.parentId === parentId && wanted.join() === siblings.join()) return; + const commands: Command[] = []; + if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId)); + commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId)); + if (parentId) this.expanded.add(parentId); + this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands)); + } + /** * Lists another holder's objects – the Container tab hands in its template, the Scene tab the * active scene again. diff --git a/src/ui/index.ts b/src/ui/index.ts index 1a71a731..9a7e30ac 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -11,7 +11,7 @@ export { Checkbox } from './widgets/Checkbox'; export { Panel } from './widgets/Panel'; export { Splitter } from './widgets/Splitter'; export { ScrollContainer } from './widgets/ScrollContainer'; -export { ListView, type ListItem, type PickModifiers } from './widgets/ListView'; +export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { Popup, type PopupOptions } from './widgets/Popup'; export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu'; export { Dialog, type DialogButton } from './widgets/Dialog'; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 320ff78f..8f2ad1ea 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -28,13 +28,19 @@ export interface PickModifiers { shift: boolean; } +/** + * Where a drop would land. `onto` is the row itself (the tree meaning: inside it), `before` and + * `after` are the gaps around it, `end` is the empty space below the last row. + */ +export type DropSpot = { at: 'onto' | 'before' | 'after'; targetId: string } | { at: 'end' }; + interface DragState { id: string; startX: number; startY: number; /** Set once the pointer travelled past `DRAG_THRESHOLD`. */ active: boolean; - targetId: string | null; + spot: DropSpot | null; } /** @@ -53,8 +59,8 @@ export class ListView extends Widget { onActivate?: (id: string) => void; /** Returning the opened menu keeps its row marked for as long as it is up. */ onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void; - /** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */ - onDrop?: (draggedId: string, targetId: string | null) => void; + /** Called when a dragged row is released somewhere that could take it. */ + onDrop?: (draggedId: string, spot: DropSpot) => void; /** * Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list * a tree: every row reserves the chevron slot, so labels stay aligned across a level. @@ -196,7 +202,7 @@ export class ListView extends Widget { if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id); if (e.detail === 2) this.onActivate?.(item.id); else if (this.onDrop) { - this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null }; + this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null }; } } } @@ -210,34 +216,57 @@ export class ListView extends Widget { this.cursor = 'grabbing'; } const local = this.toLocal(e.global); - const index = Math.floor(local.y / this.rowHeight); - const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined; - drag.targetId = target && target.id !== drag.id ? target.id : null; + drag.spot = this.spotAt(local.x, local.y, drag.id); this.invalidate(); } + /** + * Where a drop at this point would land. The outer quarter of a row is its gap, so the same + * drag can put a row inside another (middle) or next to it (edge); a point on the dragged row + * itself, or outside the list, takes nothing. + */ + private spotAt(x: number, y: number, draggedId: string): DropSpot | null { + if (x < 0 || x > this.w || y < 0) return null; + const index = Math.floor(y / this.rowHeight); + const item = this.items[index]; + if (!item) return { at: 'end' }; + if (item.id === draggedId) return null; + const offset = y - index * this.rowHeight; + const edge = this.rowHeight / 4; + if (offset < edge) return { at: 'before', targetId: item.id }; + if (offset > this.rowHeight - edge) return { at: 'after', targetId: item.id }; + return { at: 'onto', targetId: item.id }; + } + private onDragEnd(): void { const drag = this.drag; this.drag = null; this.cursor = 'default'; if (!drag?.active) return; this.invalidate(); - this.onDrop?.(drag.id, drag.targetId); + if (drag.spot) this.onDrop?.(drag.id, drag.spot); } - /** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */ + /** Outlines the row a drag would drop into, or draws a line in the gap it would drop between. */ private drawDropMarker(): void { this.dropMarker.clear(); - const drag = this.drag; - if (!drag?.active) return; - const index = this.items.findIndex((i) => i.id === drag.targetId); - if (index >= 0) { + const spot = this.drag?.active ? this.drag.spot : null; + if (!spot) return; + const line = (y: number): void => { + this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent); + }; + if (spot.at === 'end') { + line(Math.min(this.h, this.items.length * this.rowHeight) - 1); + return; + } + const index = this.items.findIndex((i) => i.id === spot.targetId); + if (index < 0) return; + if (spot.at === 'onto') { this.dropMarker .rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2) .stroke({ width: 2, color: Theme.colors.accent }); } else { - const y = Math.min(this.h, this.items.length * this.rowHeight) - 1; - this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent); + line((index + (spot.at === 'after' ? 1 : 0)) * this.rowHeight); } } } diff --git a/tests/core/aspect.test.ts b/tests/core/aspect.test.ts index 339d5fc9..821014e1 100644 --- a/tests/core/aspect.test.ts +++ b/tests/core/aspect.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'; import { CreateTextureCommand } from '../../src/core/commands/assetCommands'; import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import type { PropertyBag } from '../../src/core/model/Properties'; -import { fitAxisToAspect, matchesAspect, texturePixelSize } from '../../src/core/properties/aspect'; +import { + fitAxisToAspect, + matchesAspect, + matchesPixelSize, + texturePixelSize, +} from '../../src/core/properties/aspect'; import { makeSpriteScene } from '../helpers'; const FILE = `${'a1b2c3d4'.repeat(8)}.png`; @@ -54,6 +59,17 @@ describe('fitAxisToAspect', () => { }); }); +describe('matchesPixelSize', () => { + const size = { w: 64, h: 48 }; + + it('asks for both sides, unlike the proportions', () => { + expect(matchesPixelSize(size, props({ width: 64, height: 48 }))).toBe(true); + // Proportional, but half the size: the "set to 64 × 48 px" action still has something to do. + expect(matchesPixelSize(size, props({ width: 32, height: 24 }))).toBe(false); + expect(matchesAspect(size, props({ width: 32, height: 24 }))).toBe(true); + }); +}); + describe('matchesAspect', () => { it('compares proportions, not sizes', () => { const size = { w: 64, h: 48 }; diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts index 9373b327..396d61a7 100644 --- a/tests/core/container.test.ts +++ b/tests/core/container.test.ts @@ -8,6 +8,7 @@ import { PlaceAssetCommand, RemoveSceneObjectCommand, RemoveSceneObjectsCommand, + ReorderObjectCommand, ClearOverrideCommand, RenameSceneObjectCommand, SetOverrideCommand, @@ -333,6 +334,10 @@ describe('the structure of a placed container', () => { expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow( /comes from a container template/, ); + // Order is structure too: it comes from the template, so it is changed there. + expect(() => stack.execute(new ReorderObjectCommand(sceneId, child(tA), child(tB)))).toThrow( + /comes from a container template/, + ); }); it('refuses to take an object of the scene as a child', () => { diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index cee1ce5b..f4b0a8ad 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -3,6 +3,7 @@ import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { MoveSceneObjectCommand, RemoveSceneObjectCommand, + ReorderObjectCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph'; @@ -11,6 +12,7 @@ import { descendantsOf, findObject, localPositionFor, + type ObjectTree, treeOrder, visibleTreeOrder, } from '../../src/core/model/hierarchy'; @@ -175,6 +177,50 @@ describe('scene hierarchy', () => { }); }); +describe('ReorderObjectCommand', () => { + /** Ids in tree order – the order the panel lists and the tie-break for equal `z`. */ + const order = (scene: ObjectTree) => treeOrder(scene).map((e) => e.object.id); + + it('moves an object in front of a sibling and undo puts it back', () => { + const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); + expect(order(sceneOf())).toEqual([a, b, c]); + stack.execute(new ReorderObjectCommand(sceneId, c, a)); + expect(order(sceneOf())).toEqual([c, a, b]); + stack.undo(); + expect(order(sceneOf())).toEqual([a, b, c]); + }); + + it('moves it behind the last sibling when no successor is named', () => { + const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); + stack.execute(new ReorderObjectCommand(sceneId, a, '')); + expect(order(sceneOf())).toEqual([b, c, a]); + }); + + it('orders children among themselves, leaving the subtree where it is', () => { + const f = threeBoxes(); + const { stack, sceneId, a, b, c } = f; + const x = place(f, f.spriteId, { x: 0, y: 0 }, a); + const y = place(f, f.spriteId, { x: 0, y: 0 }, a); + expect(order(f.sceneOf())).toEqual([a, x, y, b, c]); + stack.execute(new ReorderObjectCommand(sceneId, y, x)); + expect(order(f.sceneOf())).toEqual([a, y, x, b, c]); + // The move is among siblings only: the parent keeps its own place in the list. + expect(f.object(y).parentId).toBe(a); + }); + + it('refuses a target that is not a sibling, so a reorder never re-parents by accident', () => { + const f = threeBoxes(); + const child = place(f, f.spriteId, { x: 0, y: 0 }, f.a); + expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, child))).toThrow( + /among its own siblings/, + ); + expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, f.b))).toThrow( + /in front of itself/, + ); + expect(order(f.sceneOf())).toEqual([f.a, child, f.b, f.c]); + }); +}); + describe('runtime hierarchy', () => { it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { const f = threeBoxes(); diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs index 28d380eb..28443e5f 100644 --- a/tools/e2e/07-parenting.mjs +++ b/tools/e2e/07-parenting.mjs @@ -127,4 +127,38 @@ export async function parenting(t) { o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2), ); + + // 14d. The gap above a row is a different drop than the row itself: it orders the dragged + // object among that row's siblings instead of putting it inside, and moves nothing. + const rowsBefore = await json('__gm.hierarchyOrder()'); + check( + 'the blue crate is listed after the red one to begin with', + rowsBefore.indexOf('o2') > rowsBefore.indexOf('o1'), + JSON.stringify(rowsBefore), + ); + const redRow3 = await locate('Red crate'); + const blueRow3 = await locate('Blue crate'); + // 10 px above the centre of a 26 px row is its top quarter – the gap, not the row. + await drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10); + await sleep(150); + const rowsAfter = await json('__gm.hierarchyOrder()'); + o2 = await object('o2'); + check( + 'dropping into the gap reorders instead of parenting', + rowsAfter.indexOf('o2') < rowsAfter.indexOf('o1') && o2.parentId === '', + JSON.stringify({ rowsAfter, parentId: o2.parentId }), + ); + check( + 'and a reorder moves nothing in the world', + o2.position.x === 120 && o2.position.y === -80, + JSON.stringify(o2.position), + ); + await shot('31-reordered'); + await undo(); + await sleep(150); + check( + 'undo puts the order back', + same(await json('__gm.hierarchyOrder()'), rowsBefore), + JSON.stringify(await json('__gm.hierarchyOrder()')), + ); } diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs index 8481ed54..a272fd94 100644 --- a/tools/e2e/15-aspect.mjs +++ b/tools/e2e/15-aspect.mjs @@ -1,6 +1,8 @@ /** * The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the - * picture the sprite wears, and not exist at all while it wears none. + * picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears + * no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks + * below distinguish. * * Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately * different shape, which is what makes "sheet or frame?" a question the numbers can answer. @@ -14,6 +16,14 @@ const spriteProp = (key) => `__gm.state.project.assets.find((a) => a.id === 'tex export async function aspect(t) { const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t; + /** Whether the size button of a row is there at all, in either its plain or its menu form. */ + const sizeButton = async (glyph) => + (await json(`__gm.locate('${glyph}')`)) + ? 'plain' + : (await json(`__gm.locate('${glyph} ▾')`)) + ? 'menu' + : ''; + await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''"); @@ -30,23 +40,23 @@ export async function aspect(t) { await select({ kind: 'asset', assetId: 'tex-sprite' }); await sleep(600); check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')"))); - check( - 'no texture, no aspect buttons', - (await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null, - ); + check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === ''); await shot('40-aspect-without-a-texture'); - // 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is out of proportion – both appear. + // 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size, + // so both rows offer two things and show the menu form. await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`); await sleep(500); check( - 'assigning a texture brings both buttons out', - Boolean(await json("__gm.locate('↔')")) && Boolean(await json("__gm.locate('↕')")), + 'a texture brings both buttons out, offering fit and pixel size', + (await sizeButton('↔')) === 'menu' && (await sizeButton('↕')) === 'menu', ); await shot('41-aspect-buttons'); - // 28. ↔ moves the width only, to the sheet's ratio: 100 x 64/48. - await clickText('↔'); + // 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48. + await clickText('↔ ▾'); + await sleep(400); + await clickText('Fit to 64 × 48'); await sleep(500); const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; check( @@ -55,16 +65,39 @@ export async function aspect(t) { JSON.stringify(fitted), ); check( - 'and both buttons go once the sprite is proportional', - (await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null, + 'and the fit drops out of the menu once the sprite is proportional', + (await sizeButton('↔')) === 'plain' && (await sizeButton('↕')) === 'plain', ); await shot('42-aspect-fitted'); - // 28a. One command, so it is one undo step – and the buttons come back with the old shape. + // 28a. One command, so it is one undo step – and the menu form comes back with the old shape. await undo(); await sleep(500); check('undo takes the width back', (await json(spriteProp('width'))) === 100); - check('and the buttons return with it', Boolean(await json("__gm.locate('↔')"))); + check('and the buttons return with it', (await sizeButton('↔')) === 'menu'); + await t.redo(); + await sleep(500); + + // 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and + // the whole button goes because there is nothing left to offer. + await clickText('↔'); + await sleep(500); + const pixels = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; + check( + 'the pixel-size action sets both sides to the picture', + pixels.w === 64 && pixels.h === 48, + JSON.stringify(pixels), + ); + check( + 'and nothing is left to offer, so no button is drawn', + (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '', + ); + await undo(); + await sleep(500); + check( + 'both sides come back in one undo step', + near(await json(spriteProp('width')), (100 * 64) / 48) && (await json(spriteProp('height'))) === 100, + ); await t.redo(); await sleep(500); @@ -75,10 +108,12 @@ export async function aspect(t) { asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 }); ${spriteProp('texture')} = 'aspect-frame'`); await sleep(500); - check('a frame of another shape brings the buttons back', Boolean(await json("__gm.locate('↕')"))); + check('a frame of another shape brings the buttons back', (await sizeButton('↕')) === 'menu'); const before = await json(spriteProp('width')); - await clickText('↕'); + await clickText('↕ ▾'); + await sleep(400); + await clickText('Fit to 40 × 10'); await sleep(500); const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; check( @@ -86,10 +121,7 @@ export async function aspect(t) { near(framed.h, (before * 10) / 40) && framed.w === before, JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }), ); - check( - 'and the buttons go again', - (await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null, - ); + check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain'); // 30. The same rows on a placed object write an override rather than the asset value. await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`); @@ -97,11 +129,13 @@ export async function aspect(t) { await sleep(600); await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90"); await sleep(400); - check('the object row offers the fit as well', Boolean(await json("__gm.locate('↔')"))); - // The crowded case: an overridden, out-of-proportion row carries ↕ next to ↑ and ↺. + check('the object row offers the fit as well', (await sizeButton('↔')) === 'menu'); + // The crowded case: an overridden, out-of-proportion row carries ↔ ▾ next to ↑ and ↺. check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0); await shot('43-aspect-beside-the-override-buttons'); - await clickText('↔'); + await clickText('↔ ▾'); + await sleep(400); + await clickText('Fit to 64 × 48'); await sleep(500); const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); check( @@ -110,4 +144,22 @@ export async function aspect(t) { JSON.stringify({ override, asset: await json(spriteProp('width')) }), ); await shot('44-aspect-on-an-object'); + + // 30a. The pixel size on an object writes both overrides, and as one undo step. + await clickText('↔'); + await sleep(500); + const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); + check( + 'the pixel size writes both overrides at once', + sized.width === 64 && sized.height === 48, + JSON.stringify(sized), + ); + await undo(); + await sleep(500); + const back = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); + check( + 'and one undo takes both back', + near(back.width, (90 * 64) / 48) && back.height === 90, + JSON.stringify(back), + ); } diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index 000b1001..f92474d5 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -49,7 +49,7 @@ const spawnerGraph = { }; const project = { - formatVersion: 1, + formatVersion: 2, id: projectId, name: 'E2E fixture', activeSceneId: 'scene1',