UNPKG

@unblessed/layout

Version:
692 lines (685 loc) 22.9 kB
'use strict'; 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