lazy-widgets
Version:
Typescript retained mode GUI for the HTML canvas API
580 lines • 25.5 kB
JavaScript
import { FlexAlignment } from '../theme/FlexAlignment.js';
import { Alignment } from '../theme/Alignment.js';
import { MultiParent } from './MultiParent.js';
import { PropagationModel } from '../events/WidgetEvent.js';
const FLEXBOX_EPSILON = 1e-6;
const FLEXBOX_ITER_MAX = 8;
/**
* A {@link MultiParent} which automatically paints children, adds spacing,
* propagates events and handles layout.
*
* Can be constrained to a specific type of children.
*
* Note that there is no padding. Put this inside a {@link Container} if padding
* is needed.
*
* @category Widget
*/
export class MultiContainer extends MultiParent {
constructor(vertical, children, properties) {
// MultiContainers clear their own background, have children and
// propagate events
super([], properties);
/** The unused space along the main axis after resolving dimensions */
this.unusedSpace = 0;
/** The number of enabled children in this container */
this.enabledChildCount = 0;
this.vertical = vertical;
if (children) {
this.add(children);
}
}
onThemeUpdated(property = null) {
super.onThemeUpdated(property);
if (property === null || property === 'multiContainerAlignment'
|| property === 'multiContainerSpacing') {
this._layoutDirty = true;
}
}
handleEvent(baseEvent) {
if (baseEvent.propagation !== PropagationModel.Trickling) {
return super.handleEvent(baseEvent);
}
// Reverse children if necessary
// XXX use iterator instead of _children because an event might trigger
// an action that removes a child, and the iterator creates a copy of
// the children list
let children = this;
const event = baseEvent;
if (event.reversed) {
children = Array.from(children).reverse();
}
// Find which widget the event should go to
for (const child of children) {
// Ignore disabled children
if (!child.enabled) {
continue;
}
// Stop if event was captured
const captured = child.dispatchEvent(event);
if (captured !== null) {
return captured;
}
}
// Event wasn't dispatched to any child
return null;
}
handlePreLayoutUpdate() {
// Pre-layout update children
for (const child of this._children) {
child.preLayoutUpdate();
// If child's layout is dirty, set own layoutDirty flag
if (child.layoutDirty) {
this._layoutDirty = true;
}
}
}
handlePostLayoutUpdate() {
// Post-layout update children
for (const child of this._children) {
child.postLayoutUpdate();
}
}
handleResolveDimensions(minWidth, maxWidth, minHeight, maxHeight) {
var _a;
// Resolve children's layout with loose constraints along the main axis
// to get their wanted dimensions and calculate total flex ratio
const mainIdx = this.vertical ? 1 : 0;
const crossIdx = this.vertical ? 0 : 1;
const minCrossLength = this.vertical ? minWidth : minHeight;
const maxCrossLength = this.vertical ? maxWidth : maxHeight;
let totalFlex = 0, totalFlexShrink = 0, crossLength = minCrossLength, minCrossAxis = 0;
const alignment = this.multiContainerAlignment;
let needsStretch = alignment.cross === Alignment.Stretch;
if (needsStretch) {
minCrossAxis = maxCrossLength;
if (minCrossAxis === Infinity) {
minCrossAxis = minCrossLength;
}
}
else if (alignment.cross === Alignment.SoftStretch) {
needsStretch = true;
minCrossAxis = minCrossLength;
}
const origMinCrossAxis = minCrossAxis;
this.enabledChildCount = 0;
const spacing = this.multiContainerSpacing;
let usedSpace = 0;
let usedUnshrinkableSpace = 0;
let usedUngrowableSpace = 0;
let minCrossAxisGrowIdx = 0;
const children = this._children;
const childCount = children.length;
for (let i = 0; i < childCount; i++) {
const child = children[i];
// Resolve dimensions of disabled children with zero-width
// constraints just so layout dirty flag is cleared
if (!child.enabled) {
child.resolveDimensions(0, 0, 0, 0);
continue;
}
if (this.enabledChildCount !== 0) {
usedSpace += spacing;
usedUnshrinkableSpace += spacing;
usedUngrowableSpace += spacing;
}
this.enabledChildCount++;
const basis = child.flexBasis;
if (basis === null) {
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, 0, Infinity);
}
else {
child.resolveDimensions(0, Infinity, minCrossAxis, maxHeight);
}
}
else {
const minSize = this.vertical ? child.minHeight : child.minWidth;
const maxSize = this.vertical ? child.maxHeight : child.maxWidth;
const initialSize = Math.max(Math.min(basis, maxSize), minSize);
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, initialSize, initialSize);
}
else {
child.resolveDimensions(initialSize, initialSize, minCrossAxis, maxHeight);
}
}
const childDimensions = child.idealDimensions;
const childLength = childDimensions[mainIdx];
usedSpace += childLength;
if (child.flexShrink === 0) {
usedUnshrinkableSpace += childLength;
}
if (child.flex === 0) {
usedUngrowableSpace += childLength;
}
totalFlex += child.flex;
totalFlexShrink += child.flexShrink;
const childCrossLength = childDimensions[crossIdx];
crossLength = Math.max(childCrossLength, crossLength);
if (needsStretch && childCrossLength > minCrossAxis) {
minCrossAxis = childCrossLength;
minCrossAxisGrowIdx = i;
}
}
// <NOTE stretch-cross-axis>
// If we're stretching the cross axis, but one of the later children
// caused the minimum cross length to grow, then grow the earlier
// children to correct the missing cross length. Used space will need to
// be recalculated, since the main axis length is not guaranteed to be
// unchanged (we're not doing subtraction of old size and addition of
// new size to avoid floating point error)
if (minCrossAxisGrowIdx > 0) {
usedUngrowableSpace = usedUnshrinkableSpace = usedSpace = spacing * (this.enabledChildCount - 1);
for (let i = 0; i < childCount; i++) {
const child = children[i];
if (!child.enabled) {
continue;
}
if (i < minCrossAxisGrowIdx) {
const basis = child.flexBasis;
if (basis === null) {
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, 0, Infinity);
}
else {
child.resolveDimensions(0, Infinity, minCrossAxis, maxHeight);
}
}
else {
const minSize = this.vertical ? child.minHeight : child.minWidth;
const maxSize = this.vertical ? child.maxHeight : child.maxWidth;
const initialSize = Math.max(Math.min(basis, maxSize), minSize);
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, initialSize, initialSize);
}
else {
child.resolveDimensions(initialSize, initialSize, minCrossAxis, maxHeight);
}
}
}
const childLength = child.idealDimensions[mainIdx];
usedSpace += childLength;
if (child.flexShrink === 0) {
usedUnshrinkableSpace += childLength;
}
if (child.flex === 0) {
usedUngrowableSpace += childLength;
}
}
}
// If we haven't reached the minimum length, treat it as the maximum
// length so that the empty space needed to fit the required minimum
// length is distributed properly
let targetLength = usedSpace;
const minLength = this.vertical ? minHeight : minWidth;
const maxLength = this.vertical ? maxHeight : maxWidth;
if (usedSpace < minLength) {
targetLength = minLength;
}
if (usedSpace > maxLength) {
targetLength = maxLength;
}
// Don't do flexbox calculations if free space is infinite
// (unconstrained main axis) or if there isn't any free space.
const freeSpace = targetLength - usedSpace;
if (freeSpace === Infinity || freeSpace === 0 || (freeSpace > 0 && totalFlex <= 0) || (freeSpace < 0 && totalFlexShrink <= 0)) {
if (this.vertical) {
this.idealWidth = crossLength;
this.idealHeight = Math.max(Math.min(usedSpace, maxHeight), minHeight);
}
else {
this.idealWidth = Math.max(Math.min(usedSpace, maxWidth), minWidth);
this.idealHeight = crossLength;
}
this.unusedSpace = Math.max(freeSpace, 0);
// Shrink children where necessary (first children get priority
// since flexShrink is not being used), and use strict constraints
// on the cross axis, so that stretch alignment works along it
// (otherwise maxWidth/maxHeight might never be set to a finite
// value, causing stretch to never apply). Only re-apply strict
// constraints if the maximum cross axis length was infinite.
const needsStrictCross = (maxCrossLength === Infinity);
if (needsStrictCross || freeSpace < 0) {
let spaceLeft = targetLength;
for (let i = 0; i < childCount; i++) {
// Ignore disabled children
const child = children[i];
if (!child.enabled) {
continue;
}
if (spaceLeft < child.idealDimensions[mainIdx]) {
if (this.vertical) {
child.resolveDimensions(minCrossAxis, crossLength, spaceLeft, spaceLeft);
}
else {
child.resolveDimensions(spaceLeft, spaceLeft, minCrossAxis, crossLength);
}
spaceLeft = 0;
}
else {
if (needsStrictCross) {
const wantedLength = child.idealDimensions[this.vertical ? 1 : 0];
if (this.vertical) {
child.resolveDimensions(minCrossAxis, crossLength, wantedLength, wantedLength);
}
else {
child.resolveDimensions(wantedLength, wantedLength, minCrossAxis, crossLength);
}
}
spaceLeft = Math.max(0, spaceLeft - child.idealDimensions[mainIdx] - spacing);
}
}
}
return;
}
// Resolve children's layout with constraints restricted to distributed
// free space. Calculate used space after flexbox calculations. Loosely
// follows the w3c flexbox algorithm:
// https://www.w3.org/TR/css-flexbox-1/#resolve-flexible-lengths
const flexBaseSizes = new Array();
const unclampedTargetMainSizes = new Array();
const targetMainSizes = new Array();
const scaledFlexRatios = new Array();
const frozen = new Array();
const shrink = freeSpace < 0;
const potentialSlack = targetLength - (shrink ? usedUnshrinkableSpace : usedUngrowableSpace);
let remainingThawedFreeSpace = potentialSlack;
let scaledFlexTotal = 0;
// calculate flex base sizes, and initial sizes that respect flex basis
// more accurately
for (const child of this._children) {
// Ignore disabled/inflexible children
const childFlex = shrink ? child.flexShrink : child.flex;
if (!child.enabled || childFlex <= 0) {
continue;
}
const baseSize = (_a = child.flexBasis) !== null && _a !== void 0 ? _a : child.idealDimensions[mainIdx];
flexBaseSizes.push(baseSize);
const minSize = this.vertical ? child.minHeight : child.minWidth;
const maxSize = this.vertical ? child.maxHeight : child.maxWidth;
const hypotheticalMainSize = Math.max(Math.min(baseSize, maxSize), minSize);
unclampedTargetMainSizes.push(0);
remainingThawedFreeSpace -= baseSize;
if (shrink) {
if (baseSize < hypotheticalMainSize) {
targetMainSizes.push(hypotheticalMainSize);
frozen.push(true);
}
else {
targetMainSizes.push(baseSize);
frozen.push(false);
scaledFlexTotal += childFlex * baseSize;
}
}
else {
if (baseSize > hypotheticalMainSize) {
targetMainSizes.push(hypotheticalMainSize);
frozen.push(true);
}
else {
targetMainSizes.push(baseSize);
frozen.push(false);
scaledFlexTotal += childFlex;
}
}
}
// update wanted lengths
for (let j = 0; j < FLEXBOX_ITER_MAX && Math.abs(scaledFlexTotal) > 0; j++) {
let i = 0;
let nextRemainingThawedFreeSpace = potentialSlack;
let nextScaledFlexTotal = 0;
let totalViolation = 0;
// XXX we deviate from the spec here. they have a special case for
// flex factors less than 1, which can cause an overflow, but we
// don't want that behaviour in lazy-widgets
// distribute free space and find min/max violations
for (const child of this._children) {
// Ignored disabled/inflexible children
const childFlex = shrink ? child.flexShrink : child.flex;
if (!child.enabled || childFlex <= 0) {
continue;
}
if (!frozen[i]) {
let scaledFlex;
const basis = flexBaseSizes[i];
if (shrink) {
scaledFlex = childFlex * basis;
}
else {
scaledFlex = childFlex;
}
const minSize = this.vertical ? child.minHeight : child.minWidth;
const maxSize = this.vertical ? child.maxHeight : child.maxWidth;
let childUnclampedSize;
if (shrink) {
childUnclampedSize = basis - scaledFlex * Math.abs(remainingThawedFreeSpace) / scaledFlexTotal;
}
else {
childUnclampedSize = basis + scaledFlex * remainingThawedFreeSpace / scaledFlexTotal;
}
const childClampedSize = Math.max(Math.min(childUnclampedSize, maxSize), minSize);
totalViolation += childClampedSize - childUnclampedSize;
unclampedTargetMainSizes[i] = childUnclampedSize;
targetMainSizes[i] = childClampedSize;
scaledFlexRatios[i] = scaledFlex;
}
i++;
}
// freeze over-flexed items
let allFrozen = true;
if (totalViolation === 0) {
// freeze all
}
else if (totalViolation > 0) {
// freeze items with min violations
i = 0;
for (const child of this._children) {
// Ignored disabled/inflexible/frozen children
const childFlex = shrink ? child.flexShrink : child.flex;
if (!child.enabled || childFlex <= 0) {
continue;
}
const childClampedSize = targetMainSizes[i];
if (frozen[i]) {
nextRemainingThawedFreeSpace -= childClampedSize;
}
else {
if (childClampedSize > unclampedTargetMainSizes[i]) {
nextRemainingThawedFreeSpace -= childClampedSize;
frozen[i] = true;
}
else {
nextRemainingThawedFreeSpace -= flexBaseSizes[i];
nextScaledFlexTotal += scaledFlexRatios[i];
allFrozen = false;
}
}
i++;
}
}
else if (totalViolation < 0) {
// freeze items with max violations
i = 0;
for (const child of this._children) {
// Ignored disabled/inflexible/frozen children
const childFlex = shrink ? child.flexShrink : child.flex;
if (!child.enabled || childFlex <= 0) {
continue;
}
const childClampedSize = targetMainSizes[i];
if (frozen[i]) {
nextRemainingThawedFreeSpace -= childClampedSize;
}
else {
if (childClampedSize < unclampedTargetMainSizes[i]) {
nextRemainingThawedFreeSpace -= childClampedSize;
frozen[i] = true;
}
else {
nextRemainingThawedFreeSpace -= flexBaseSizes[i];
nextScaledFlexTotal += scaledFlexRatios[i];
allFrozen = false;
}
}
i++;
}
}
if (allFrozen) {
break;
}
remainingThawedFreeSpace = nextRemainingThawedFreeSpace;
scaledFlexTotal = nextScaledFlexTotal;
}
// apply wanted lengths
let i = 0;
let iEnabled = 0;
let usedSpaceAfter = 0;
crossLength = minCrossAxis = origMinCrossAxis;
minCrossAxisGrowIdx = 0;
for (const child of this._children) {
// Ignore disabled children
if (!child.enabled) {
continue;
}
// Add spacing to used space if this is not the first widget
if (iEnabled !== 0) {
usedSpaceAfter += spacing;
}
const childFlex = shrink ? child.flexShrink : child.flex;
let wantedLength = childFlex > 0 ? targetMainSizes[i++] : child.idealDimensions[mainIdx];
if (wantedLength + usedSpaceAfter > targetLength) {
wantedLength = Math.max(0, targetLength - usedSpaceAfter);
}
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, wantedLength, wantedLength);
}
else {
child.resolveDimensions(wantedLength, wantedLength, minCrossAxis, maxHeight);
}
const childCrossLength = child.idealDimensions[crossIdx];
if (crossLength < childCrossLength) {
crossLength = childCrossLength;
if (needsStretch) {
minCrossAxis = childCrossLength;
minCrossAxisGrowIdx = iEnabled;
// XXX reset used space because we're going to have to
// recalculate it for all of the widgets that come
// before this one
usedSpaceAfter = 0;
}
}
usedSpaceAfter += child.idealDimensions[mainIdx];
iEnabled++;
}
// see <NOTE stretch-cross-axis>
if (minCrossAxisGrowIdx > 0) {
i = 0;
for (iEnabled = 0; iEnabled < minCrossAxisGrowIdx; iEnabled++) {
const child = children[iEnabled];
if (!child.enabled) {
continue;
}
// XXX we're guaranteed to come before a widget, and we're
// missing the spacing for the widget at minCrossAxisGrowIdx
// so we don't need the iEnabled !== 0 check that we did
// before here; instead of adding the spacing before the
// widget, we're adding the spacing that comes after the
// widget
usedSpaceAfter += spacing;
const childFlex = shrink ? child.flexShrink : child.flex;
let wantedLength = childFlex > 0 ? targetMainSizes[i++] : child.idealDimensions[mainIdx];
if (wantedLength + usedSpaceAfter > targetLength) {
wantedLength = Math.max(0, targetLength - usedSpaceAfter);
}
if (this.vertical) {
child.resolveDimensions(minCrossAxis, maxWidth, wantedLength, wantedLength);
}
else {
child.resolveDimensions(wantedLength, wantedLength, minCrossAxis, maxHeight);
}
usedSpaceAfter += child.idealDimensions[mainIdx];
}
}
// Resolve width and height
if (this.vertical) {
this.idealWidth = crossLength;
this.idealHeight = targetLength;
}
else {
this.idealWidth = targetLength;
this.idealHeight = crossLength;
}
// Calculate final unused space; used for alignment. Clamp to zero just
// in case XXX is that neccessary?
this.unusedSpace = Math.max(targetLength - usedSpaceAfter, 0);
}
resolvePosition(x, y) {
super.resolvePosition(x, y);
// Align children
const alignment = this.multiContainerAlignment;
const around = alignment.main === FlexAlignment.SpaceAround;
const between = alignment.main === FlexAlignment.SpaceBetween || around;
const mainRatio = (between ? 0 : alignment.main);
const crossRatio = ((alignment.cross === Alignment.Stretch || alignment.cross === Alignment.SoftStretch) ? 0 : alignment.cross);
const effectiveChildren = this.enabledChildCount - 1 + (around ? 2 : 0);
let extraSpacing;
if (effectiveChildren <= 0) {
extraSpacing = 0;
}
else {
extraSpacing = this.unusedSpace / effectiveChildren;
}
let spacing = this.multiContainerSpacing;
if (between) {
spacing += extraSpacing;
}
let mainOffset = (this.vertical ? y : x) + mainRatio * this.unusedSpace;
if (around) {
mainOffset += extraSpacing;
}
for (const child of this._children) {
// Ignore disabled children
if (!child.enabled) {
continue;
}
const [childWidth, childHeight] = child.idealDimensions;
if (this.vertical) {
child.resolvePosition(x + crossRatio * (this.idealWidth - childWidth), mainOffset);
mainOffset += childHeight + spacing;
}
else {
child.resolvePosition(mainOffset, y + crossRatio * (this.idealHeight - childHeight));
mainOffset += childWidth + spacing;
}
}
}
handlePainting(dirtyRects) {
// Paint children
for (const child of this._children) {
child.paint(dirtyRects);
}
}
}
MultiContainer.autoXML = {
name: 'multi-container',
inputConfig: [
{
mode: 'value',
name: 'vertical',
validator: 'boolean',
},
{
mode: 'widget',
name: 'children',
list: true,
optional: true,
}
]
};
//# sourceMappingURL=MultiContainer.js.map