Tabs are open documents: the tab row in Concepts, opening assets in Behaviors, Containers, Animations, Tiles, fixed plugin tabs, Architecture, check count 1085 (#171)

admin committed 2026-09-19 15:49:54 +02:00
1 parent ff256e8b92
commit a95b3efbfe
8 files changed
+68 -44

No files matched your search

+2 -1
@@ -49,7 +49,8 @@ right half is the start of the way, the left half its end, a round half a slow o
linear, a circle ease in-out, a half-round key ease in or ease out, a square a step. Player and
preview evaluate it in one place, so both show the same curve.
**The Animation tab.** The header picks the animation, what it **animates** (the root), **+ Row**
**The Animation tab.** Each animation opens in a tab of its own. The header picks what it
**animates** (the root), **+ Row**
(a dialog: the root or one of its objects, then a property of that; the inspector's
*Change target…* reopens it to move a row's keys to another property of the same kind), **Play**
/ **Pause** and **Fit**. Below the header the **preview** shows the root at the playhead, drawn
+16 -13
@@ -44,20 +44,19 @@
measures from it — only what is drawn is shortened — and a subclass that puts something else
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
the Behavior, Texture, Animation and Container pickers keep their size whatever they show. The
tab row has no floor at all: it is label-wide at half the theme padding, and `TabStrip` drops
the Animation root and Tile kind pickers keep their size whatever they show. The
tab row has no floor at all: it is label-wide at half the theme padding, a document's name stops
at 160 px (`DocumentTab`), and `TabStrip` drops
what does not fit into an overflow menu rather than letting it spill (the rule is
`tabRowLayout` in `editor/tabRow.ts`, so it is checkable without a browser; `__gm.tabRow()`
reports what is in the row and what went away).
reports what is in the row, what went away and the whole order).
A button that can draw its label bold — on an accent fill: primary,
danger, a toggled tab — is measured bold, or a dialog's `Delete` comes out as `Del…`.
The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph
instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks
for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any
visible text drawn wider than the widget owning it, `__gm.labelsDropped()` guards the other
direction — a button too narrow to draw any of its label — and the `pickerWidth()` of
`__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its
content (e2e step 14).
direction — a button too narrow to draw any of its label (e2e step 14).
- **Vite + TypeScript**, **Vitest** for core, toolkit and editor logic.
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
atomic writes and an integer revision that rejects stale saves (HTTP 409).
@@ -104,7 +103,11 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
fileImport (drop/paste) → assetImport (one upload pipeline, an ImportKind per sort) ← textureImport / fontImport / soundImport,
FilePicker (the shared "+ → Texture / Font / Sound" dialog; ImagePicker/FontPicker/SoundPicker only describe their row),
framing (fit/marquee/snap maths), FormDialog (the small modal forms: label column, Cancel + confirm, one open at a time), AssetTabEditor (the shared shell of the asset tabs), TabHeader (the bar across the top of a tab – asset tabs, Plugins tab, NodeBoard),
TabStrip + tabRow (the tab row and what it does when the tabs outgrow it; plugin tabs are appended at run time)
TabStrip + tabRow (the tab row and what it does when the tabs outgrow it; plugin tabs are inserted at run time),
DocumentTab (a document's tab: kind, name, close cross), documents (#171: where a new document's tab goes, which one shows
after a close, a tab going on to another document — pure, so checkable without a browser).
One editor per kind serves every open document of that kind: `Editor` points it at the one on
screen (`AssetTabEditor.showAsset`), and it keeps each document's view through `saveView`/`restoreView`
src/editor/plugins/ startPlugins (list, import, host) → loader (PluginManager: order, checks, switching on and off, what became of each file), pluginApi (API v1, the per-plugin scope), PluginsPane (the Plugins tab), NodeBoard (api.ui)
src/editor/viewport/ WorldViewport base → FreeViewport (a camera of its own: fit, pan, wheel zoom) → SceneViewport, PreviewCanvas;
GameViewport follows the game's camera instead — editing tools, hence not in src/viewport/
@@ -115,19 +118,19 @@ src/editor/inspector/ Inspector panel, host (what a section builds into), field
src/editor/panels/ AssetPanel, assetMenu (the two groups of the + menu), HierarchyPanel, Toolbar,
TilemapPanel (tools, tiles and layers of the one selected tilemap, beside the scene view, #142;
painting itself is SceneViewport)
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, graphView (zoom limits, grid, panAndZoom – shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus,
src/editor/graph/ GraphEditor (header, breadcrumb), GraphCanvas, graphView (zoom limits, grid, panAndZoom – shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus,
NodeView, NodePalette + paletteEntries (which also ranks a search), folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses),
AnnotationView + annotationDrag (groups and comments: what a drag carries, each thing moved once)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog (a FormDialog), sheetView (the grid maths),
src/editor/container/ ContainerEditor (header), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header), TextureCanvas (picture + frames), SliceDialog (a FormDialog), sheetView (the grid maths),
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
src/editor/animation/ AnimationEditor (header + picker, preview over timeline), PreviewCanvas (the root at the playhead, a FreeViewport
src/editor/animation/ AnimationEditor (header, preview over timeline), PreviewCanvas (the root at the playhead, a FreeViewport
over animationPreview.ts), TimelineCanvas (rows, keys, ruler, clipboard), timelineView (its geometry),
RowDialog + FramesDialog (FormDialogs)
src/editor/tile/ TileEditor (header + picker + kind), TileCanvas (every tile of the block and a sample map, animated),
src/editor/tile/ TileEditor (header + kind), TileCanvas (every tile of the block and a sample map, animated),
PictureStrip (the pictures as animation steps; dropIndex is the reorder maths),
TilePalette (the tiles as a grid of pictures in the tilemap panel, #139)
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
src/editor/statemachine/ StateMachineEditor (header, on the graph canvas)
src/editor/particles/ ParticlesEditor (graph canvas left of a preview panel, splitter between them),
ParticlePreview (the panel: the asset's own particles, wearing its own rows)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
+8 -9
@@ -184,8 +184,9 @@ shown on the node and editable in the inspector.
Three ways in and out of a call: **drag** a behavior out of the asset panel onto the graph and it
lands as a call where you let go; **double-click** a call node to open the graph it runs. The
way down stays above the graph as a **breadcrumb** (`Game > Move > Other`): click a crumb to go
back there; the first one returns to where you started and the bar goes away. Opening a behavior
any other way — the asset panel, the scene, the picker — ends the trail. And a call that cannot
back there; the first one returns to where you started and the bar goes away. Following a call
stays in the same tab, and the trail belongs to that tab: a behavior opened any other way comes
up in a tab of its own, without a trail, and switching back brings the trail back with it. And a call that cannot
do anything **says so on the node** — no behavior picked, the behavior deleted,
or no start node in it. The last one is the one worth naming: without a start node there is no
way in, so the call is skipped and the flow simply carries on at its `exec` output. A missing
@@ -299,8 +300,7 @@ shown on the node and editable in the inspector.
- All of it is drawing only: `groups` and `notes` on the graph, saved with the project, read by
no interpreter. A graph that has neither stores neither array, so an old document means exactly
what it says. Every graph tab has them — behaviors, state machines, shaders, particles.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
- **Header bar** (the same one the Texture and Container tabs have): **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
height, **and every group and comment with them** — a fit that framed the nodes alone would cut
the box drawn around them, and a graph holding nothing but a comment would frame the empty
origin; `editor/framing.ts`), and the size of the graph as "N nodes · M links". It stops zooming
@@ -310,11 +310,10 @@ shown on the node and editable in the inspector.
switching it on never shuffles a graph that is already laid out, and a node nobody drags never
moves. Every node of a selection snaps **for itself**, so dragging a group is how you line one
up; the offset is a decision about rectangles and lives with the others in `editor/framing.ts`.
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
all) leaves the tab on the behavior you were editing, because checking a sprite's position
must not empty your work. Only a behavior that no longer exists is dropped, and then the tab
falls back to another one — the picker is how you switch on purpose.
- **One tab per behavior**: each behavior opens as a document of its own (double-click in the
asset panel), several at once, each keeping its zoom and scroll for the session. A selection
never changes what a tab shows; selecting a node of another graph brings that graph's tab up.
A deleted behavior closes its tab.
- **Live view**: Play always switches to the Game tab; switch to Behavior while playing and
the exec edges and nodes that just ran light up — the same canvas draws editor and game, so
this costs nothing extra. Each fire of an exec edge also sends a bead along it (0.3 s, at most
+31 -9
@@ -45,9 +45,10 @@ rate. The **Assets** panel's `+` creates an asset and opens its tab; its menu li
two alphabetical groups, first what can be placed into a scene, then what only lives in the library
(#43) — the line between them answers the question that follows right after "which type?".
A click on a row only
selects (the inspector shows it, no tab moves); a **double-click** opens the tab the asset lives
in — Scene, Behavior, State machine, Shader, Particles, Texture, Animation or Container — and on a scene
also makes it the active one. `Enter` does the same once the list has keyboard focus (`Tab`).
selects (the inspector shows it, no tab moves); a **double-click** opens the asset as a document
in a tab of its own (see **The tab row**) — a behavior, state machine, shader, particles,
texture, animation, tile or container — and on a scene switches to the Scene tab and makes it the
active one. `Enter` does the same once the list has keyboard focus (`Tab`).
A sprite, camera, text or collider has no tab of its own, so a double-click on one does nothing.
Right-clicking a row offers *Set as active scene*, *Set as start scene* / *Clear start scene*,
*Create sprite from texture*, *Create animation from texture* and *Delete…*. In
@@ -57,12 +58,33 @@ The asset panel and **Scene objects** share the left column; the divider between
the height from one to the other, and dragged all the way either keeps its header. The split is a
share of the column, so a taller or shorter window keeps it.
**The tab row.** The centre column carries one button per tab. When the column is too narrow to
hold them all — a small window, or the side panels dragged wide — the ones that do not fit move
behind a `»` button at the end of the row, which lists them by name; picking one opens it and
brings it into the row. The tab you are editing is always in the row: if it has to, the row gives
up every other tab to keep it there. Nothing is ever drawn past the column's edge, where it would
sit under the inspector and take clicks meant for it.
**The tab row.** A tab is an **open document**, as in an IDE (#171). **Scene**, **Game** and
**Plugins** are fixed and cannot be closed; a plugin's own tab follows them for as long as the
plugin is on. Everything else opens as a document: double-click (or `Enter`) an asset in the
asset panel, create one, or open a list from its row. An open document only comes up again, a new
one gets its tab right of the document on screen (or last, from a fixed tab). Several assets of
one kind can be open at once — two textures, three behaviors — each with its own tab.
- The tab reads the kind in muted type, then the name: `Behavior · Player`, `Texture · Player`,
so assets of the same name stay apart. A long name is cut with an ellipsis. Renaming an asset
renames its tab.
- A document tab always shows its own asset. Selecting something elsewhere never changes it; there
is no picker in the tab's header any more. Selecting what lives only inside a document — a node,
a frame, an object of a container — brings that document up.
- The **close cross** at the end of the tab, or a **middle click**, closes it; nothing is lost, every edit is in the
project already. Closing the tab on screen shows its left neighbour (the right one when it was
first), the Scene tab when none is left. Deleting an asset closes its tab.
- Each document keeps its **zoom and scroll** for the session: switch between two textures and
both are where you left them. Following a call node into another behavior stays in the same
tab, and the breadcrumb stays with that tab.
- Open tabs are not remembered: a loaded project starts on the Scene tab.
When the column is too narrow to hold every tab — a small window, many documents, or the side
panels dragged wide — the ones that do not fit move behind a `»` button at the end of the row,
which lists them by kind and name; picking one opens it and brings it into the row. The tab you
are editing is always in the row: if it has to, the row gives up every other tab to keep it there.
Nothing is ever drawn past the column's edge, where it would sit under the inspector and take
clicks meant for it.
**The console.** Under whichever centre tab is showing sits the **Console** panel, always
visible and resized by the divider above it — dragged all the way shut it keeps its own header
+6 -7
@@ -11,15 +11,14 @@ the template, with the same drag-to-parent, context menu and folding as a scene;
a scene asset and a container asset are both `ObjectHolder`s, and every object command takes a
`holderId` that is either one.
**Header bar**: the container picker (searchable), **Fit** (frames the content, origin always
included) and the size of the template as "N objects". Placing needs no button of its own: an
**Header bar**: **Fit** (frames the content, origin always included) and the size of the
template as "N objects". Placing needs no button of its own: an
asset dragged from the panel onto this view goes into the template, and a container dragged onto
its own template is refused while it hovers rather than at the drop.
Like the Behavior tab it follows the selection without being bound to it — the container asset, one of
its placed copies or one of its template objects shows it, anything else leaves the tab on what you
were editing, and only a deleted container makes it fall back to another one. Selecting a template
*object* also switches to the tab, the rule a graph node and a texture frame already follow: it
exists in no other view. Selecting the asset itself does not; double-clicking it does.
Each container opens as a document in a tab of its own (see *The tab row* in
[Concepts](Concepts)); several can be open at once. Selecting a template *object* brings its
container's tab up, the rule a graph node and a texture frame already follow: it exists in no other
view. Selecting the asset itself does not; double-clicking it does.
Placing a container anywhere creates **one** object. Its content is never copied: `resolveObjects`
(`src/core/model/instance.ts`) weaves the template in whenever the holder is read, one level per
+2 -2
@@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1076 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1085 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
@@ -64,7 +64,7 @@ The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, asse
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts,
particles, shared scope, render on change, sounds, load scene, graph groups, nine-slice, plugins, tiles, tilemaps) that build on each other's
particles, shared scope, render on change, sounds, load scene, graph groups, nine-slice, plugins, tiles, tilemaps, physics, shapes, queries, documents) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises.
+1 -1
@@ -75,7 +75,7 @@ A switch takes effect at once, without reloading the editor:
| `api.version` | `1` |
| `api.project.assets()` | The assets of the open project in list order, as `{ id, name, type }` – copies, so writing to them changes nothing. |
| `api.project.on('change', fn)` | Calls `fn` whenever the asset list changes: an asset created, renamed or deleted, by hand, by undo or by loading another project. A change that leaves ids, names and types alone (moving an object, editing a property) does not call it. Returns a function that unsubscribes. A listener that throws is reported and stays subscribed. Any event but `'change'` throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the built-in ones. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken – by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the fixed ones (Scene, Game, Plugins), ahead of the open documents; it stays for as long as the plugin is on. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken – by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `new api.ui.NodeBoard()` | A read-only node graph: `setNodes([{ id, title, x, y }])` replaces what it shows, `fit()` frames it. It has a **Fit** button and a node count above the canvas; a drag with any button pans, the wheel zooms about the pointer. The nodes are drawn by the graph editor's own node view, empty but for their title – no ports, no editing, no selection. The first `setNodes` with anything in it frames the nodes once the tab has a size; after that the view stays where the user put it. A title that is not text is empty, a position that is not a number is 0, and something that is not a list throws. |
## The demo plugin
+2 -2
@@ -76,8 +76,8 @@ Kind, rate, playback, the solid switch and the collision scale are ordinary prop
## The tab
- The **picker** in the header chooses the tile, the one beside it the kind, **Fit** frames the
board. The readout on the right says kind, tile size, pictures, rate and how many tiles the block
- Each tile opens in a tab of its own. The **picker** in the header chooses the kind, **Fit**
frames the board. The readout on the right says kind, tile size, pictures, rate and how many tiles the block
produces — or *not usable yet* with the reason written under the board.
- The **board** shows every tile the block can produce, captioned with the neighbours that ask for
it: `NESW-nw` is "joined on all four sides, with a notch towards the north-west", `·` is a lone