Head the groups of the inspector with bands that fold (#290)
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m10s

The groups of the inspector had a small grey label as their title, in
the same grid as a property row, so with many rows one group hardly
stood apart from the next. Now every group has a band as its head,
from edge to edge of the panel, and a click on it folds the group.

- A band runs across the padding of its column in the header colour,
  with the title and a mark that says whether the group is open. The
  titles stay as they are, as answered in the issue.
- A click on a band folds its group: the rows go, the band stays and
  the next band moves up against it. A control in the band (Open,
  + Variable, + Property) does its own job and folds nothing.
- A placed object has a group SWITCHES under its name, asset and
  parent: Enabled, Keep on scene load and the switches of a behavior.
  An asset has SWITCHES and PROPERTIES, and a group without rows is
  left out.
- The same bands head the tilemap panel, the Variables tab, the side
  of the Pixel art tab and the shape list of vector art. A list with a
  title (Layers, Frames, Shapes) folds with a click on its header,
  clear of its plus.
- ui/sections.ts holds the rules as pure functions, SectionStack is
  the column and SectionHeader the band. Every title of the inspector
  goes through addTitle or sectionHeader in inspector/host.ts.

Decided here:

- A fold is kept for good, in localStorage of this browser. The issue
  asked whether for the session or for good and the answer left it
  open.
- A fold is remembered by title, per panel. PROPERTIES folded once is
  folded on every object, asset and node. A number in a title does
  not count.
- A behavior asset has all its rows under SWITCHES. A Variables asset
  and a state machine get a group SCOPE.
- A greyed out control in a band takes the click too. Without that the
  click fell through to the band and folded the group.
- The Curve tab has no column of this kind and is unchanged.

Found on the way:

- The inspector of a sprite is taller than the 800 pixel window of
  the checks now. The hooks that hand out a row scroll it into view
  first, and module 28 scrolls before it looks for a row by its text.

Tests:
- Gate: 202 files, 3008 tests (2982 + 26), exit 0
- e2e: no complete run for this state. Modules 28 to 61 passed alone
  (749 checks). A full run was cut off in the accounts module at 1492
  passes without a failure, the modules after it and the smokes are
  missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it.
- 26 unit tests in tests/editor/sections.test.ts
- Four e2e checks with a real mouse, three in module 02 and one in
  module 48
- 25 unit counter-probes all fail tests, run on stein2
- Screenshots of the bands looked at before the last build, none
  after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 22:08:28 +02:00
1 parent c5ba21533d
commit 393caadc9d
22 files changed
+1072 -53

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { hiddenBySections, sectionOffsets, type SectionRow, sectionStartOf } from '../sections';
import { type Size, Widget } from '../Widget';
import { type StackOptions, VStack } from './Stack';
/**
* A column of rows in groups (#290): a `SectionHeader` runs across the padding as a band, the rows
* after it stand inside it, and a folded header hides its rows down to the next one. Children are
* never stretched along the column – it is meant for a scroll container.
*/
export class SectionStack extends VStack {
/** After a group was folded or unfolded – the owner measures the column again. */
onFold: () => void = () => {};
/** The children a fold hid, so unfolding shows only those again. */
private readonly hiddenByFold = new WeakSet<Widget>();
constructor(options: StackOptions = {}) {
super(options);
}
refold(): void {
this.applyFolds();
this.onFold();
}
private applyFolds(): void {
const widgets = this.children.filter((c): c is Widget => c instanceof Widget);
const hidden = hiddenBySections(widgets.map((w) => sectionStartOf(w)?.sectionCollapsed ?? null));
widgets.forEach((widget, i) => {
if (hidden[i] && widget.visible) {
widget.visible = false;
this.hiddenByFold.add(widget);
} else if (!hidden[i] && this.hiddenByFold.has(widget)) {
widget.visible = true;
this.hiddenByFold.delete(widget);
}
});
}
private rows(sizes: readonly Size[]): SectionRow[] {
return this.items.map((item, i) => {
const start = sectionStartOf(item);
return {
h: sizes[i]!.h,
start: start !== null,
band: start !== null && (start.sectionStart === 'band' || start.sectionCollapsed),
};
});
}
override measure(): Size {
this.applyFolds();
const sizes = this.items.map((item) => item.measure());
const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2;
const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) };
}
override layout(): void {
this.applyFolds();
const items = this.items;
const sizes = items.map((item) => item.measure());
const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding);
items.forEach((item, i) => {
const band = sectionStartOf(item)?.sectionStart === 'band';
item.position.set(band ? 0 : this.padding, Math.round(ys[i]!));
item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h));
});
}
}