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; 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(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'; } }