Files
feuerwerk/src/ui/layout/Stack.ts
T
admin bc57f61c91
game-maker / suite (push) Successful in 10m46s
A still editor has nothing to draw, so the frame loop wakes on change (#44)
The renderer is taken off the ticker; the ticker itself keeps running, so
the clock, a live world and the panels still get their step. Drawing now
happens inside a 200 ms trailing window that every pointer, wheel and key
event, every Widget.invalidate/resize, every Stack.relayout, every
EditorState.emit and both library onChange hooks reopen. The window rather
than a single frame is what makes the ~70 direct redraw() calls that never
pass through invalidate() safe, along with repaints an event only starts:
a microtask, a texture upload, a resolved font.

Editor.movesByItself() keeps asking for every frame while Play or an editor
run is on, a time shader is on screen, or the particle or animation preview
is showing.

The FPS counter now counts drawn frames over the time the renderer was
awake. Idle seconds are not slow seconds, so it answers "how smooth while
it moves" and holds its last answer instead of falling to 0 at rest.

Measured on the built stand, 20 s per variant, CPU of all chromium
processes from /proc: kept awake 75.5 % of a core at 60 fps, render on
change 8.8 % with 0 frames drawn, awake again 83.9 % as an order probe.

No colour check can see a missing repaint: pixelAt extracts from the stage
on demand and is just as green on a screen frozen a minute ago. Step 33
watches the new __gm.renderStats() counter instead.
2026-09-14 21:41:50 +02:00

122 lines
3.9 KiB
TypeScript

import { requestRender } from '../render';
import { type Size, Widget } from '../Widget';
export interface StackOptions {
gap?: number;
padding?: number;
/** Cross-axis alignment of children that are smaller than the stack. */
align?: 'stretch' | 'start' | 'center' | 'end';
}
/**
* Lays out child widgets in a row or column. Children with `flex > 0` share, weighted, the space
* left after the fixed ones took their measured size. Non-widget children are still rendered.
*/
export abstract class Stack extends Widget {
gap: number;
padding: number;
align: NonNullable<StackOptions['align']>;
constructor(
protected readonly axis: 'x' | 'y',
options: StackOptions = {},
) {
super();
this.gap = options.gap ?? 0;
this.padding = options.padding ?? 0;
this.align = options.align ?? 'stretch';
}
/** The children space is distributed among: visible widgets, in child order. */
get items(): Widget[] {
return this.children.filter((c): c is Widget => c instanceof Widget && c.visible);
}
/** Does not lay out; call `relayout()` once the stack is sized. */
add<T extends Widget>(widget: T, flex = 0): T {
widget.flex = flex;
this.addChild(widget);
return widget;
}
override measure(): Size {
let main = 0;
let cross = 0;
const items = this.items;
for (const item of items) {
const size = item.measure();
main += this.axis === 'x' ? size.w : size.h;
cross = Math.max(cross, this.axis === 'x' ? size.h : size.w);
}
main += Math.max(0, items.length - 1) * this.gap + this.padding * 2;
cross += this.padding * 2;
const w = this.axis === 'x' ? main : cross;
const h = this.axis === 'x' ? cross : main;
return { w: Math.max(w, this.minWidth), h: Math.max(h, this.minHeight) };
}
override layout(): void {
const items = this.items;
const mainTotal = (this.axis === 'x' ? this.w : this.h) - this.padding * 2;
const crossTotal = (this.axis === 'x' ? this.h : this.w) - this.padding * 2;
const sizes = items.map((item) => item.measure());
let fixed = Math.max(0, items.length - 1) * this.gap;
let flexSum = 0;
items.forEach((item, i) => {
if (item.flex > 0) flexSum += item.flex;
else fixed += this.axis === 'x' ? sizes[i]!.w : sizes[i]!.h;
});
const free = Math.max(0, mainTotal - fixed);
let cursor = this.padding;
items.forEach((item, i) => {
const size = sizes[i]!;
const mainSize = item.flex > 0 ? (free * item.flex) / flexSum : this.axis === 'x' ? size.w : size.h;
const preferredCross = this.axis === 'x' ? size.h : size.w;
const crossSize = this.align === 'stretch' ? crossTotal : Math.min(preferredCross, crossTotal);
let crossPos = this.padding;
if (this.align === 'center') crossPos += (crossTotal - crossSize) / 2;
else if (this.align === 'end') crossPos += crossTotal - crossSize;
if (this.axis === 'x') {
item.position.set(Math.round(cursor), Math.round(crossPos));
item.resize(Math.round(mainSize), Math.round(crossSize));
} else {
item.position.set(Math.round(crossPos), Math.round(cursor));
item.resize(Math.round(crossSize), Math.round(mainSize));
}
cursor += mainSize + this.gap;
});
}
/** Re-run the layout with the current size (after children were added/removed/hidden). */
relayout(): void {
this.layout();
this.redraw();
// A stack that only moves children around resizes none of them, so this is its own wake-up.
requestRender();
}
}
export class VStack extends Stack {
constructor(options: StackOptions = {}) {
super('y', options);
}
}
export class HStack extends Stack {
constructor(options: StackOptions = {}) {
super('x', options);
}
}
/** Empty widget used as a flexible spacer or fixed-size gap. */
export class Spacer extends Widget {
constructor(size = 0) {
super();
this.minWidth = size;
this.minHeight = size;
this.eventMode = 'none';
}
}