@unblessed/layout
Version:
Flexbox layout engine for unblessed using Yoga
692 lines (685 loc) • 22.9 kB
JavaScript
;
var Yoga2 = require('yoga-layout');
var core = require('@unblessed/core');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var Yoga2__default = /*#__PURE__*/_interopDefault(Yoga2);
// src/layout-engine.ts
function bindEventHandlers(widget, handlers) {
for (const [event, handler] of Object.entries(handlers)) {
widget.on(event, handler);
}
}
function unbindEventHandlers(widget, handlers) {
for (const [event, handler] of Object.entries(handlers)) {
widget.removeListener(event, handler);
}
}
function getComputedLayout(node) {
return {
top: Math.round(node.yogaNode.getComputedTop()),
left: Math.round(node.yogaNode.getComputedLeft()),
width: Math.round(node.yogaNode.getComputedWidth()),
height: Math.round(node.yogaNode.getComputedHeight())
};
}
function syncWidgetWithYoga(node, screen) {
const layout = getComputedLayout(node);
let top = layout.top;
let left = layout.left;
if (node.parent) {
const parentBorderTop = node.parent.yogaNode.getComputedBorder(
Yoga2__default.default.EDGE_TOP
);
const parentBorderLeft = node.parent.yogaNode.getComputedBorder(
Yoga2__default.default.EDGE_LEFT
);
top = layout.top - parentBorderTop;
left = layout.left - parentBorderLeft;
}
if (node.type === "#text") {
if (node.widget) {
return node.widget;
}
node.widget = new core.Element({ screen, hidden: true });
return node.widget;
}
if (!node.widget) {
if (node._descriptor) {
const adjustedLayout = { ...layout, top, left };
node.widget = node._descriptor.createWidget(adjustedLayout, screen);
} else {
node.widget = new core.Box({
screen,
tags: true,
mouse: true,
keys: true,
top,
left,
width: layout.width,
height: layout.height,
...node.widgetOptions
});
}
if (node.eventHandlers && Object.keys(node.eventHandlers).length > 0) {
bindEventHandlers(node.widget, node.eventHandlers);
node._boundHandlers = node.eventHandlers;
}
} else {
const adjustedLayout = { ...layout, top, left };
if (node._descriptor) {
node._descriptor.updateWidget(node.widget, adjustedLayout);
} else {
node.widget.position.top = top;
node.widget.position.left = left;
node.widget.position.width = layout.width;
node.widget.position.height = layout.height;
if (node.widgetOptions) {
Object.assign(node.widget, node.widgetOptions);
}
}
if (node.eventHandlers !== node._boundHandlers) {
if (node._boundHandlers) {
unbindEventHandlers(node.widget, node._boundHandlers);
}
if (Object.keys(node.eventHandlers || {}).length > 0) {
bindEventHandlers(node.widget, node.eventHandlers || {});
node._boundHandlers = node.eventHandlers;
} else {
node._boundHandlers = void 0;
}
}
}
const widget = node.widget;
for (const child of node.children) {
const childWidget = syncWidgetWithYoga(child, screen);
if (childWidget.parent !== widget) {
widget.append(childWidget);
}
}
if (node.children.length > 0) {
const allTextNodes = node.children.every((c) => c.type === "#text");
if (allTextNodes) {
const fullContent = node.children.map((c) => c.widgetOptions?.content || "").join("");
if (fullContent) {
widget.setContent(fullContent);
}
}
}
return widget;
}
function syncTreeAndRender(rootNode, screen) {
const rootWidget = syncWidgetWithYoga(rootNode, screen);
if (!rootWidget.parent) {
screen.append(rootWidget);
}
screen.render();
}
function destroyWidgets(node) {
for (const child of node.children) {
destroyWidgets(child);
}
if (node.widget) {
if (node._boundHandlers) {
unbindEventHandlers(node.widget, node._boundHandlers);
node._boundHandlers = void 0;
}
node.widget.detach();
node.widget.destroy();
node.widget = void 0;
}
}
function createLayoutNode(type, props = {}) {
const config = Yoga2__default.default.Config.create();
config.setUseWebDefaults(true);
const yogaNode = Yoga2__default.default.Node.create(config);
if (type === "box" && !props.width) {
props.width = "100%";
}
const node = {
type,
yogaNode,
props,
children: [],
parent: null,
widgetOptions: {}
};
applyFlexStyles(yogaNode, props);
return node;
}
function applyFlexStyles(yogaNode, props) {
if (props.flexDirection !== void 0) {
switch (props.flexDirection) {
case "row":
yogaNode.setFlexDirection(Yoga2__default.default.FLEX_DIRECTION_ROW);
break;
case "column":
yogaNode.setFlexDirection(Yoga2__default.default.FLEX_DIRECTION_COLUMN);
break;
case "row-reverse":
yogaNode.setFlexDirection(Yoga2__default.default.FLEX_DIRECTION_ROW_REVERSE);
break;
case "column-reverse":
yogaNode.setFlexDirection(Yoga2__default.default.FLEX_DIRECTION_COLUMN_REVERSE);
break;
}
}
if (props.flexWrap !== void 0) {
switch (props.flexWrap) {
case "nowrap":
yogaNode.setFlexWrap(Yoga2__default.default.WRAP_NO_WRAP);
break;
case "wrap":
yogaNode.setFlexWrap(Yoga2__default.default.WRAP_WRAP);
break;
case "wrap-reverse":
yogaNode.setFlexWrap(Yoga2__default.default.WRAP_WRAP_REVERSE);
break;
}
}
if (props.justifyContent !== void 0) {
switch (props.justifyContent) {
case "flex-start":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_FLEX_START);
break;
case "center":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_CENTER);
break;
case "flex-end":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_FLEX_END);
break;
case "space-between":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_SPACE_BETWEEN);
break;
case "space-around":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_SPACE_AROUND);
break;
case "space-evenly":
yogaNode.setJustifyContent(Yoga2__default.default.JUSTIFY_SPACE_EVENLY);
break;
}
}
if (props.alignItems !== void 0) {
switch (props.alignItems) {
case "flex-start":
yogaNode.setAlignItems(Yoga2__default.default.ALIGN_FLEX_START);
break;
case "center":
yogaNode.setAlignItems(Yoga2__default.default.ALIGN_CENTER);
break;
case "flex-end":
yogaNode.setAlignItems(Yoga2__default.default.ALIGN_FLEX_END);
break;
case "stretch":
yogaNode.setAlignItems(Yoga2__default.default.ALIGN_STRETCH);
break;
}
}
if (props.alignSelf !== void 0) {
switch (props.alignSelf) {
case "auto":
yogaNode.setAlignSelf(Yoga2__default.default.ALIGN_AUTO);
break;
case "flex-start":
yogaNode.setAlignSelf(Yoga2__default.default.ALIGN_FLEX_START);
break;
case "center":
yogaNode.setAlignSelf(Yoga2__default.default.ALIGN_CENTER);
break;
case "flex-end":
yogaNode.setAlignSelf(Yoga2__default.default.ALIGN_FLEX_END);
break;
case "stretch":
yogaNode.setAlignSelf(Yoga2__default.default.ALIGN_STRETCH);
break;
}
}
if (props.flexGrow !== void 0) {
yogaNode.setFlexGrow(props.flexGrow);
}
if (props.flexShrink !== void 0) {
yogaNode.setFlexShrink(props.flexShrink);
}
if (props.flexBasis !== void 0) {
if (typeof props.flexBasis === "number") {
yogaNode.setFlexBasis(props.flexBasis);
} else if (typeof props.flexBasis === "string") {
const value = parsePercentage(props.flexBasis);
if (value !== null) {
yogaNode.setFlexBasisPercent(value);
}
}
}
if (props.width !== void 0) {
if (typeof props.width === "number") {
yogaNode.setWidth(props.width);
} else if (typeof props.width === "string") {
const value = parsePercentage(props.width);
if (value !== null) {
yogaNode.setWidthPercent(value);
} else if (props.width === "auto") {
yogaNode.setWidthAuto();
}
}
}
if (props.height !== void 0) {
if (typeof props.height === "number") {
yogaNode.setHeight(props.height);
} else if (typeof props.height === "string") {
const value = parsePercentage(props.height);
if (value !== null) {
yogaNode.setHeightPercent(value);
} else if (props.height === "auto") {
yogaNode.setHeightAuto();
}
}
}
if (props.minWidth !== void 0) {
if (typeof props.minWidth === "number") {
yogaNode.setMinWidth(props.minWidth);
} else if (typeof props.minWidth === "string") {
const value = parsePercentage(props.minWidth);
if (value !== null) {
yogaNode.setMinWidthPercent(value);
}
}
}
if (props.minHeight !== void 0) {
if (typeof props.minHeight === "number") {
yogaNode.setMinHeight(props.minHeight);
} else if (typeof props.minHeight === "string") {
const value = parsePercentage(props.minHeight);
if (value !== null) {
yogaNode.setMinHeightPercent(value);
}
}
}
if (props.maxWidth !== void 0) {
if (typeof props.maxWidth === "number") {
yogaNode.setMaxWidth(props.maxWidth);
} else if (typeof props.maxWidth === "string") {
const value = parsePercentage(props.maxWidth);
if (value !== null) {
yogaNode.setMaxWidthPercent(value);
}
}
}
if (props.maxHeight !== void 0) {
if (typeof props.maxHeight === "number") {
yogaNode.setMaxHeight(props.maxHeight);
} else if (typeof props.maxHeight === "string") {
const value = parsePercentage(props.maxHeight);
if (value !== null) {
yogaNode.setMaxHeightPercent(value);
}
}
}
if (props.padding !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_TOP, props.padding);
yogaNode.setPadding(Yoga2__default.default.EDGE_BOTTOM, props.padding);
yogaNode.setPadding(Yoga2__default.default.EDGE_LEFT, props.padding);
yogaNode.setPadding(Yoga2__default.default.EDGE_RIGHT, props.padding);
}
if (props.paddingX !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_HORIZONTAL, props.paddingX);
}
if (props.paddingY !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_VERTICAL, props.paddingY);
}
if (props.paddingTop !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_TOP, props.paddingTop);
}
if (props.paddingBottom !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_BOTTOM, props.paddingBottom);
}
if (props.paddingLeft !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_LEFT, props.paddingLeft);
}
if (props.paddingRight !== void 0) {
yogaNode.setPadding(Yoga2__default.default.EDGE_RIGHT, props.paddingRight);
}
if (props.margin !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_TOP, props.margin);
yogaNode.setMargin(Yoga2__default.default.EDGE_BOTTOM, props.margin);
yogaNode.setMargin(Yoga2__default.default.EDGE_LEFT, props.margin);
yogaNode.setMargin(Yoga2__default.default.EDGE_RIGHT, props.margin);
}
if (props.marginX !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_HORIZONTAL, props.marginX);
}
if (props.marginY !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_VERTICAL, props.marginY);
}
if (props.marginTop !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_TOP, props.marginTop);
}
if (props.marginBottom !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_BOTTOM, props.marginBottom);
}
if (props.marginLeft !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_LEFT, props.marginLeft);
}
if (props.marginRight !== void 0) {
yogaNode.setMargin(Yoga2__default.default.EDGE_RIGHT, props.marginRight);
}
if (props.gap !== void 0) {
yogaNode.setGap(Yoga2__default.default.GUTTER_ALL, props.gap);
}
if (props.columnGap !== void 0) {
yogaNode.setGap(Yoga2__default.default.GUTTER_COLUMN, props.columnGap);
}
if (props.rowGap !== void 0) {
yogaNode.setGap(Yoga2__default.default.GUTTER_ROW, props.rowGap);
}
if (props.position !== void 0) {
switch (props.position) {
case "relative":
yogaNode.setPositionType(Yoga2__default.default.POSITION_TYPE_RELATIVE);
break;
case "absolute":
yogaNode.setPositionType(Yoga2__default.default.POSITION_TYPE_ABSOLUTE);
break;
}
}
if (props.display !== void 0) {
switch (props.display) {
case "flex":
yogaNode.setDisplay(Yoga2__default.default.DISPLAY_FLEX);
break;
case "none":
yogaNode.setDisplay(Yoga2__default.default.DISPLAY_NONE);
break;
}
}
if (props.border !== void 0) {
yogaNode.setBorder(Yoga2__default.default.EDGE_TOP, props.border);
yogaNode.setBorder(Yoga2__default.default.EDGE_BOTTOM, props.border);
yogaNode.setBorder(Yoga2__default.default.EDGE_LEFT, props.border);
yogaNode.setBorder(Yoga2__default.default.EDGE_RIGHT, props.border);
}
if (props.borderTop !== void 0) {
yogaNode.setBorder(Yoga2__default.default.EDGE_TOP, props.borderTop);
}
if (props.borderBottom !== void 0) {
yogaNode.setBorder(Yoga2__default.default.EDGE_BOTTOM, props.borderBottom);
}
if (props.borderLeft !== void 0) {
yogaNode.setBorder(Yoga2__default.default.EDGE_LEFT, props.borderLeft);
}
if (props.borderRight !== void 0) {
yogaNode.setBorder(Yoga2__default.default.EDGE_RIGHT, props.borderRight);
}
}
function updateLayoutNode(node, newProps) {
node.props = newProps;
applyFlexStyles(node.yogaNode, newProps);
}
function appendChild(parent, child) {
child.parent = parent;
parent.children.push(child);
parent.yogaNode.insertChild(child.yogaNode, parent.children.length - 1);
}
function removeChild(parent, child) {
const index = parent.children.indexOf(child);
if (index === -1) return;
parent.children.splice(index, 1);
parent.yogaNode.removeChild(child.yogaNode);
child.parent = null;
}
function destroyLayoutNode(node) {
node.children.forEach((child) => destroyLayoutNode(child));
node.yogaNode.freeRecursive();
node.children = [];
node.parent = null;
node.widget = void 0;
}
function parsePercentage(value) {
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
if (!match) return null;
return parseFloat(match[1]);
}
// src/layout-engine.ts
var LayoutManager = class {
constructor(options) {
this.screen = options.screen;
this.debug = options.debug ?? false;
}
/**
* Creates a new layout node.
* @param type - Type identifier for the node
* @param props - Flexbox properties
* @param widgetOptions - Additional unblessed widget options
* @returns A new layout node
*/
createNode(type, props = {}, widgetOptions = {}) {
const node = createLayoutNode(type, props);
node.widgetOptions = widgetOptions;
if (this.debug) {
console.log(`[LayoutManager] Created node: ${type}`, props);
}
return node;
}
/**
* Appends a child node to a parent.
* @param parent - Parent layout node
* @param child - Child layout node
*/
appendChild(parent, child) {
child.parent = parent;
parent.children.push(child);
parent.yogaNode.insertChild(child.yogaNode, parent.children.length - 1);
if (this.debug) {
console.log(`[LayoutManager] Appended ${child.type} to ${parent.type}`);
}
}
/**
* Inserts a child node before another child.
* @param parent - Parent layout node
* @param child - Child layout node to insert
* @param beforeChild - Reference child to insert before
*/
insertBefore(parent, child, beforeChild) {
const index = parent.children.indexOf(beforeChild);
if (index === -1) {
throw new Error("Reference child not found in parent");
}
child.parent = parent;
parent.children.splice(index, 0, child);
parent.yogaNode.insertChild(child.yogaNode, index);
if (this.debug) {
console.log(
`[LayoutManager] Inserted ${child.type} before ${beforeChild.type} in ${parent.type}`
);
}
}
/**
* Removes a child node from its parent.
* @param parent - Parent layout node
* @param child - Child layout node to remove
*/
removeChild(parent, child) {
const index = parent.children.indexOf(child);
if (index === -1) return;
parent.children.splice(index, 1);
parent.yogaNode.removeChild(child.yogaNode);
child.parent = null;
if (this.debug) {
console.log(`[LayoutManager] Removed ${child.type} from ${parent.type}`);
}
}
/**
* Calculates layout for a tree and synchronizes with unblessed widgets.
* This is the main function that bridges Yoga calculations to unblessed rendering.
*
* WORKFLOW:
* 1. Calculate layout with Yoga (flexbox positioning)
* 2. Extract computed coordinates from Yoga nodes
* 3. Create/update unblessed widgets with those coordinates
* 4. Render the screen
*
* @param rootNode - Root of the layout tree
*/
performLayout(rootNode) {
if (this.debug) {
console.log("[LayoutManager] Starting layout calculation");
}
this.calculateLayout(rootNode);
if (this.debug) {
this.logLayout(rootNode);
}
syncTreeAndRender(rootNode, this.screen);
if (this.debug) {
console.log("[LayoutManager] Layout complete and rendered");
}
}
/**
* Calculates layout using Yoga.
* After this, all Yoga nodes will have computed positions.
* @param rootNode - Root of the layout tree
*/
calculateLayout(rootNode) {
const terminalWidth = this.screen.width || 80;
if (rootNode.props.width === void 0) {
rootNode.yogaNode.setWidth(terminalWidth);
}
rootNode.yogaNode.calculateLayout(
void 0,
// Let Yoga auto-calculate based on node settings
void 0,
Yoga2__default.default.DIRECTION_LTR
);
}
/**
* Logs the computed layout for debugging.
* @param node - Node to log (recursive)
* @param depth - Current depth for indentation
*/
logLayout(node, depth = 0) {
const indent = " ".repeat(depth);
const layout = node.yogaNode.getComputedLayout();
const padding = node.yogaNode.getComputedPadding(0);
const border = node.yogaNode.getComputedBorder(0);
console.log(
`${indent}${node.type}: top=${layout.top} left=${layout.left} width=${layout.width} height=${layout.height} padding=${padding} border=${border}`
);
for (const child of node.children) {
this.logLayout(child, depth + 1);
}
}
/**
* Destroys a layout tree and cleans up all resources.
* IMPORTANT: Call this to prevent memory leaks.
* @param node - Root node to destroy
*/
destroy(node) {
if (this.debug) {
console.log(`[LayoutManager] Destroying node tree: ${node.type}`);
}
destroyWidgets(node);
destroyLayoutNode(node);
}
};
// src/widget-descriptor.ts
var WidgetDescriptor = class {
/**
* Constructor stores the props
* @param props - Typed props for this widget
*/
constructor(props) {
this.props = props;
}
/**
* Update an existing widget with new layout and options.
*
* IMPORTANT: This method must preserve runtime hover/focus state.
* When hover/focus effects are active, Screen.setEffects() modifies widget.style
* and stores original values in temporary objects (_htemp, _ftemp).
* We must not overwrite widget.style when effects are active.
*
* @param widget - Existing widget instance to update
* @param layout - New computed layout from Yoga (with border adjustments already applied)
*/
updateWidget(widget, layout) {
widget.position.top = layout.top;
widget.position.left = layout.left;
widget.position.width = layout.width;
widget.position.height = layout.height;
const options = this.widgetOptions;
const hoverTemp = widget._htemp;
const focusTemp = widget._ftemp;
const hasActiveEffects = hoverTemp || focusTemp;
if (hasActiveEffects) {
const { style, hoverEffects, focusEffects, ...otherOptions } = options;
const safeOptions = this.deepCloneOptions(otherOptions);
Object.assign(widget, safeOptions);
if (hoverTemp && hoverEffects && style) {
this.updateTempFromNewBase(hoverTemp, style, hoverEffects);
}
if (focusTemp && focusEffects && style) {
this.updateTempFromNewBase(focusTemp, style, focusEffects);
}
} else {
const safeOptions = this.deepCloneOptions(options);
Object.assign(widget, safeOptions);
}
}
/**
* Update temporary storage with new base values while preserving effect state.
* When props change during hover/focus, we need to update what values will be
* restored on mouseout/blur, but keep the current hover/focus styling active.
*
* @param temp - Temporary storage object (_htemp or _ftemp)
* @param newBaseStyle - New base style from props
* @param effects - Effects configuration (hoverEffects or focusEffects)
*/
updateTempFromNewBase(temp, newBaseStyle, effects) {
Object.keys(effects).forEach((key) => {
const effectVal = effects[key];
if (effectVal !== null && typeof effectVal === "object") {
temp[key] = temp[key] || {};
const newBase = newBaseStyle[key] || {};
Object.keys(effectVal).forEach((k) => {
temp[key][k] = newBase[k];
});
} else {
temp[key] = newBaseStyle[key];
}
});
}
/**
* Deep clone options to avoid mutating shared object references.
*
* @param options - Options object to clone
* @returns Deep cloned options object
*/
deepCloneOptions(options) {
if (!options || typeof options !== "object") {
return options;
}
const cloned = {};
for (const key in options) {
if (options.hasOwnProperty(key)) {
const val = options[key];
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
cloned[key] = this.deepCloneOptions(val);
} else {
cloned[key] = val;
}
}
}
return cloned;
}
};
exports.LayoutManager = LayoutManager;
exports.WidgetDescriptor = WidgetDescriptor;
exports.appendChild = appendChild;
exports.applyFlexStyles = applyFlexStyles;
exports.createLayoutNode = createLayoutNode;
exports.destroyLayoutNode = destroyLayoutNode;
exports.destroyWidgets = destroyWidgets;
exports.getComputedLayout = getComputedLayout;
exports.removeChild = removeChild;
exports.syncTreeAndRender = syncTreeAndRender;
exports.syncWidgetWithYoga = syncWidgetWithYoga;
exports.updateLayoutNode = updateLayoutNode;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map