From 157b932e808be07fabab4c713c3d7f678088e565 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 5 Jan 2026 11:28:04 -0500 Subject: [PATCH] feat: add flex grow/shrink --- modules/Element.lua | 215 +++++++++++++++++++++++++--- modules/LayoutEngine.lua | 294 +++++++++++++++++++++++++++++++++++---- 2 files changed, 467 insertions(+), 42 deletions(-) diff --git a/modules/Element.lua b/modules/Element.lua index 01fbdb9..44880e4 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -32,6 +32,10 @@ ---@field flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP) ---@field justifySelf JustifySelf -- Alignment of the item itself along main axis (default: AUTO) ---@field alignSelf AlignSelf -- Alignment of the item itself along cross axis (default: AUTO) +---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis (e.g., 1, "0 1 auto", "none") +---@field flexGrow number -- How much the item will grow relative to siblings (default: 0) +---@field flexShrink number -- How much the item will shrink relative to siblings (default: 1) +---@field flexBasis string|number -- Initial main size before growing/shrinking (default: "auto") ---@field textSize number? -- Resolved font size for text content in pixels ---@field minTextSize number? ---@field maxTextSize number? @@ -186,6 +190,75 @@ function Element.init(deps) Element._Performance = deps.Performance end +--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis +--- Supports: number, "auto", "none", "grow shrink basis" +---@param flexValue number|string The flex shorthand value +---@return number flexGrow +---@return number flexShrink +---@return string|number flexBasis +local function parseFlexShorthand(flexValue) + -- Single number: flex-grow + if type(flexValue) == "number" then + return flexValue, 1, 0 + end + + -- String values + if type(flexValue) == "string" then + -- "auto" = 1 1 auto + if flexValue == "auto" then + return 1, 1, "auto" + end + + -- "none" = 0 0 auto + if flexValue == "none" then + return 0, 0, "auto" + end + + -- Parse "grow shrink basis" format + local parts = {} + for part in flexValue:gmatch("%S+") do + table.insert(parts, part) + end + + local grow = 0 + local shrink = 1 + local basis = "auto" + + if #parts == 1 then + -- Single value: could be grow (number) or basis (with unit) + local num = tonumber(parts[1]) + if num then + grow = num + basis = 0 + else + basis = parts[1] + end + elseif #parts == 2 then + -- Two values: grow shrink (both numbers) or grow basis + local num1 = tonumber(parts[1]) + local num2 = tonumber(parts[2]) + if num1 and num2 then + grow = num1 + shrink = num2 + basis = 0 + elseif num1 then + grow = num1 + basis = parts[2] + end + elseif #parts >= 3 then + -- Three values: grow shrink basis + grow = tonumber(parts[1]) or 0 + shrink = tonumber(parts[2]) or 1 + basis = parts[3] + end + + return grow, shrink, basis + end + + -- Default fallback + return 0, 1, "auto" +end + ---@param props ElementProps ---@return Element function Element.new(props) @@ -571,7 +644,10 @@ function Element.new(props) end else -- Store as table only if non-zero values exist - local hasNonZero = props.cornerRadius.topLeft or props.cornerRadius.topRight or props.cornerRadius.bottomLeft or props.cornerRadius.bottomRight + local hasNonZero = props.cornerRadius.topLeft + or props.cornerRadius.topRight + or props.cornerRadius.bottomLeft + or props.cornerRadius.bottomRight if hasNonZero then self.cornerRadius = { topLeft = props.cornerRadius.topLeft or 0, @@ -612,7 +688,8 @@ function Element.new(props) -- Validate objectFit if props.objectFit then - local validObjectFit = { fill = "fill", contain = "contain", cover = "cover", ["scale-down"] = "scale-down", none = "none" } + local validObjectFit = + { fill = "fill", contain = "contain", cover = "cover", ["scale-down"] = "scale-down", none = "none" } Element._utils.validateEnum(props.objectFit, validObjectFit, "objectFit") end self.objectFit = props.objectFit or "fill" @@ -784,6 +861,7 @@ function Element.new(props) y = { value = nil, unit = "px" }, textSize = { value = nil, unit = "px" }, gap = { value = nil, unit = "px" }, + flexBasis = { value = nil, unit = "auto" }, padding = { top = { value = nil, unit = "px" }, right = { value = nil, unit = "px" }, @@ -1017,6 +1095,82 @@ function Element.new(props) self.units.gap = { value = 0, unit = "px" } end + -- Handle flex shorthand property (sets flexGrow, flexShrink, flexBasis) + if props.flex ~= nil then + local grow, shrink, basis = parseFlexShorthand(props.flex) + + -- Only set individual properties if they weren't explicitly provided + if props.flexGrow == nil then + props.flexGrow = grow + end + if props.flexShrink == nil then + props.flexShrink = shrink + end + if props.flexBasis == nil then + props.flexBasis = basis + end + end + + -- Handle flexGrow property + if props.flexGrow ~= nil then + if type(props.flexGrow) == "number" and props.flexGrow >= 0 then + self.flexGrow = props.flexGrow + else + Element._ErrorHandler:warn("Element", "FLEX_001", { + element = self.id or "unnamed", + issue = "flexGrow must be a non-negative number", + value = tostring(props.flexGrow), + }) + self.flexGrow = 0 + end + else + self.flexGrow = 0 + end + + -- Handle flexShrink property + if props.flexShrink ~= nil then + if type(props.flexShrink) == "number" and props.flexShrink >= 0 then + self.flexShrink = props.flexShrink + else + Element._ErrorHandler:warn("Element", "FLEX_002", { + element = self.id or "unnamed", + issue = "flexShrink must be a non-negative number", + value = tostring(props.flexShrink), + }) + self.flexShrink = 1 + end + else + self.flexShrink = 1 + end + + -- Handle flexBasis property + if props.flexBasis ~= nil then + local isCalc = Element._Calc and Element._Calc.isCalc(props.flexBasis) + if props.flexBasis == "auto" then + self.flexBasis = "auto" + self.units.flexBasis = { value = nil, unit = "auto" } + elseif type(props.flexBasis) == "string" or isCalc then + local value, unit = Element._Units.parse(props.flexBasis) + self.units.flexBasis = { value = value, unit = unit } + -- Don't resolve yet - LayoutEngine will handle this during layout + self.flexBasis = props.flexBasis + elseif type(props.flexBasis) == "number" then + self.flexBasis = props.flexBasis + self.units.flexBasis = { value = props.flexBasis, unit = "px" } + else + Element._ErrorHandler:warn("Element", "FLEX_003", { + element = self.id or "unnamed", + issue = "flexBasis must be a number, string, or 'auto'", + value = tostring(props.flexBasis), + }) + self.flexBasis = "auto" + self.units.flexBasis = { value = nil, unit = "auto" } + end + else + self.flexBasis = "auto" + self.units.flexBasis = { value = nil, unit = "auto" } + end + -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions -- For explicit sizing, width/height already include padding (border-box) @@ -1433,7 +1587,10 @@ function Element.new(props) else -- Default: children in flex/grid containers participate in parent's layout -- children in relative/absolute containers default to relative - if self.parent.positioning == Element._utils.enums.Positioning.FLEX or self.parent.positioning == Element._utils.enums.Positioning.GRID then + if + self.parent.positioning == Element._utils.enums.Positioning.FLEX + or self.parent.positioning == Element._utils.enums.Positioning.GRID + then self.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid self._explicitlyAbsolute = false -- Participate in parent's layout else @@ -2239,7 +2396,8 @@ function Element:getAvailableContentWidth() -- Check if the element is using the scaled 9-patch contentPadding as its padding -- Allow small floating point differences (within 0.1 pixels) local usingContentPaddingAsPadding = ( - math.abs(self.padding.left - scaledContentPadding.left) < 0.1 and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 + math.abs(self.padding.left - scaledContentPadding.left) < 0.1 + and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 ) if not usingContentPaddingAsPadding then @@ -2263,7 +2421,8 @@ function Element:getAvailableContentHeight() -- Check if the element is using the scaled 9-patch contentPadding as its padding -- Allow small floating point differences (within 0.1 pixels) local usingContentPaddingAsPadding = ( - math.abs(self.padding.top - scaledContentPadding.top) < 0.1 and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 + math.abs(self.padding.top - scaledContentPadding.top) < 0.1 + and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 ) if not usingContentPaddingAsPadding then @@ -2286,7 +2445,10 @@ function Element:addChild(child) -- If child was created without explicit positioning, inherit from parent if child._originalPositioning == nil then -- No explicit positioning was set during construction - if self.positioning == Element._utils.enums.Positioning.FLEX or self.positioning == Element._utils.enums.Positioning.GRID then + if + self.positioning == Element._utils.enums.Positioning.FLEX + or self.positioning == Element._utils.enums.Positioning.GRID + then child.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid child._explicitlyAbsolute = false -- Participate in parent's layout else @@ -2496,7 +2658,8 @@ function Element:draw(backdropCanvas) if self.animation then local anim = self.animation:interpolate() if anim.opacity then - drawBackgroundColor = Element._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity) + drawBackgroundColor = + Element._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity) end end @@ -2565,7 +2728,8 @@ function Element:draw(backdropCanvas) -- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden) local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow - local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil) + local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") + and (overflowX ~= nil or overflowY ~= nil) -- Apply scroll offset if overflow is not visible local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) @@ -2575,7 +2739,8 @@ function Element:draw(backdropCanvas) -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - local stencilFunc = Element._RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + local stencilFunc = + Element._RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) -- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround) local currentCanvas = love.graphics.getCanvas() @@ -2636,7 +2801,15 @@ function Element:draw(backdropCanvas) if self.contentBlur and self.contentBlur.radius > 0 and #sortedChildren > 0 then local blurInstance = self:getBlurInstance() if blurInstance then - Element._Blur.applyToRegion(blurInstance, self.contentBlur.radius, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren) + Element._Blur.applyToRegion( + blurInstance, + self.contentBlur.radius, + self.x, + self.y, + borderBoxWidth, + borderBoxHeight, + drawChildren + ) else drawChildren() end @@ -2826,7 +2999,12 @@ function Element:update(dt) -- Check if we should handle scrollbar press for elements with overflow local overflowX = self.overflowX or self.overflow local overflowY = self.overflowY or self.overflow - local hasScrollableOverflow = (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + ) if hasScrollableOverflow and not self._scrollbarDragging then -- Check for scrollbar press on left mouse button @@ -2918,7 +3096,8 @@ function Element:update(dt) local anyPressed = self._eventHandler:isAnyButtonPressed() -- Update theme state via ThemeManager - local newThemeState = self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled) + local newThemeState = + self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled) if self._stateId and self._elementMode == "immediate" then local hover = newThemeState == "hover" @@ -2995,8 +3174,10 @@ function Element:resize(newGameWidth, newGameHeight) self.textSize = (value / 100) * self.width -- Apply min/max constraints - local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + local minSize = self.minTextSize + and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize + and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) if minSize and self.textSize < minSize then self.textSize = minSize end @@ -3011,8 +3192,10 @@ function Element:resize(newGameWidth, newGameHeight) self.textSize = (value / 100) * self.height -- Apply min/max constraints - local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + local minSize = self.minTextSize + and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize + and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) if minSize and self.textSize < minSize then self.textSize = minSize end diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua index 67351ff..87a10bb 100644 --- a/modules/LayoutEngine.lua +++ b/modules/LayoutEngine.lua @@ -222,6 +222,123 @@ function LayoutEngine:_batchCalculatePositions(children, startX, startY, spacing return positions end +--- Calculate flex item sizes based on flexGrow, flexShrink, flexBasis +--- Implements CSS flexbox sizing algorithm +---@param children table Array of child elements in the flex line +---@param availableMainSize number Available space in main axis +---@param gap number Gap between items +---@param isHorizontal boolean Whether main axis is horizontal +---@return table mainSizes Array of calculated main sizes for each child +function LayoutEngine:_calculateFlexSizes(children, availableMainSize, gap, isHorizontal) + local childCount = #children + local totalGaps = math.max(0, childCount - 1) * gap + local availableForContent = availableMainSize - totalGaps + + -- Step 1: Calculate hypothetical main sizes (flex basis resolution) + local hypotheticalSizes = {} + local flexBases = {} + local totalFlexBasis = 0 + + for i, child in ipairs(children) do + local flexBasis = child.flexBasis + local hypotheticalSize + + -- Resolve flex-basis + if flexBasis == "auto" then + -- Use element's main size (width for horizontal, height for vertical) + if isHorizontal then + hypotheticalSize = child:getBorderBoxWidth() + else + hypotheticalSize = child:getBorderBoxHeight() + end + elseif type(flexBasis) == "number" then + hypotheticalSize = flexBasis + elseif type(flexBasis) == "string" and child.units.flexBasis then + -- Parse and resolve flex-basis with units + local value, unit = child.units.flexBasis.value, child.units.flexBasis.unit + hypotheticalSize = + self._Units.resolve(value, unit, self._Context.viewportWidth, self._Context.viewportHeight, availableMainSize) + else + -- Fallback to element's natural size + if isHorizontal then + hypotheticalSize = child:getBorderBoxWidth() + else + hypotheticalSize = child:getBorderBoxHeight() + end + end + + -- Add margins to hypothetical size + local childMargin = child.margin + if isHorizontal then + hypotheticalSize = hypotheticalSize + childMargin.left + childMargin.right + else + hypotheticalSize = hypotheticalSize + childMargin.top + childMargin.bottom + end + + flexBases[i] = hypotheticalSize + hypotheticalSizes[i] = hypotheticalSize + totalFlexBasis = totalFlexBasis + hypotheticalSize + end + + -- Step 2: Determine if we need to grow or shrink + local freeSpace = availableForContent - totalFlexBasis + + -- Step 3a: Handle positive free space (GROW) + if freeSpace > 0 then + local totalFlexGrow = 0 + for _, child in ipairs(children) do + totalFlexGrow = totalFlexGrow + (child.flexGrow or 0) + end + + if totalFlexGrow > 0 then + -- Distribute free space proportionally to flex-grow values + for i, child in ipairs(children) do + local flexGrow = child.flexGrow or 0 + if flexGrow > 0 then + local growAmount = (flexGrow / totalFlexGrow) * freeSpace + hypotheticalSizes[i] = hypotheticalSizes[i] + growAmount + end + end + end + -- Step 3b: Handle negative free space (SHRINK) + elseif freeSpace < 0 then + local totalFlexShrink = 0 + local totalScaledShrinkFactor = 0 + + for i, child in ipairs(children) do + local flexShrink = child.flexShrink or 1 + totalFlexShrink = totalFlexShrink + flexShrink + -- Scaled shrink factor = flex-shrink × flex-basis + totalScaledShrinkFactor = totalScaledShrinkFactor + (flexShrink * flexBases[i]) + end + + if totalScaledShrinkFactor > 0 then + -- Distribute shrinkage proportionally to (flex-shrink × flex-basis) + for i, child in ipairs(children) do + local flexShrink = child.flexShrink or 1 + if flexShrink > 0 then + local scaledShrinkFactor = flexShrink * flexBases[i] + local shrinkAmount = (scaledShrinkFactor / totalScaledShrinkFactor) * math.abs(freeSpace) + hypotheticalSizes[i] = math.max(0, hypotheticalSizes[i] - shrinkAmount) + end + end + end + end + + -- Step 4: Return final main sizes (excluding margins) + local mainSizes = {} + for i, child in ipairs(children) do + local childMargin = child.margin + if isHorizontal then + mainSizes[i] = math.max(0, hypotheticalSizes[i] - childMargin.left - childMargin.right) + else + mainSizes[i] = math.max(0, hypotheticalSizes[i] - childMargin.top - childMargin.bottom) + end + end + + return mainSizes +end + --- Layout children within this element according to positioning mode function LayoutEngine:layoutChildren() -- Start performance timing first (before any early returns) @@ -398,13 +515,58 @@ function LayoutEngine:layoutChildren() end end + -- Apply flex sizing to each line BEFORE calculating line heights + -- Performance optimization: hoist enum comparison outside loop + local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL + + for lineIndex, line in ipairs(lines) do + -- Check if any child in this line needs flex sizing + local needsFlexSizing = false + for _, child in ipairs(line) do + if (child.flexGrow and child.flexGrow > 0) or (child.flexBasis and child.flexBasis ~= "auto") then + needsFlexSizing = true + break + end + end + + -- Only apply flex sizing if needed + if needsFlexSizing then + -- Calculate flex sizes for this line + local mainSizes = self:_calculateFlexSizes(line, availableMainSize, self.gap, isHorizontal) + + -- Apply calculated sizes to children + for i, child in ipairs(line) do + local mainSize = mainSizes[i] + + if isHorizontal then + -- Update width for horizontal flex + child.width = mainSize + child._borderBoxWidth = mainSize + -- Invalidate width cache + child._borderBoxWidthCache = nil + else + -- Update height for vertical flex + child.height = mainSize + child._borderBoxHeight = mainSize + -- Invalidate height cache + child._borderBoxHeightCache = nil + end + + -- Trigger layout for child's children if any + if #child.children > 0 then + child:layoutChildren() + end + end + end + end + -- Calculate line positions and heights (including child padding) -- Performance optimization: preallocate array if possible local lineHeights = table.create and table.create(#lines) or {} local totalLinesHeight = 0 - -- Performance optimization: hoist enum comparison outside loop - local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL + -- Performance optimization: hoist enum comparison outside loop (already hoisted above) + -- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL for lineIndex, line in ipairs(lines) do local maxCrossSize = 0 @@ -435,7 +597,11 @@ function LayoutEngine:layoutChildren() -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size if #lines == 1 then - if self.alignItems == self._AlignItems.STRETCH or self.alignItems == self._AlignItems.CENTER or self.alignItems == self._AlignItems.FLEX_END then + if + self.alignItems == self._AlignItems.STRETCH + or self.alignItems == self._AlignItems.CENTER + or self.alignItems == self._AlignItems.FLEX_END + then -- STRETCH, CENTER, and FLEX_END should use full available cross size lineHeights[1] = availableCrossSize totalLinesHeight = availableCrossSize @@ -574,7 +740,11 @@ function LayoutEngine:layoutChildren() if effectiveAlign == alignItems_FLEX_START then child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop elseif effectiveAlign == alignItems_CENTER then - child.y = elementY + elementPaddingTop + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + childMarginTop + child.y = elementY + + elementPaddingTop + + currentCrossPos + + ((lineHeight - childTotalCrossSize) / 2) + + childMarginTop elseif effectiveAlign == alignItems_FLEX_END then child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop elseif effectiveAlign == alignItems_STRETCH then @@ -615,7 +785,11 @@ function LayoutEngine:layoutChildren() if effectiveAlign == alignItems_FLEX_START then child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft elseif effectiveAlign == alignItems_CENTER then - child.x = elementX + elementPaddingLeft + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + childMarginLeft + child.x = elementX + + elementPaddingLeft + + currentCrossPos + + ((lineHeight - childTotalCrossSize) / 2) + + childMarginLeft elseif effectiveAlign == alignItems_FLEX_END then child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft elseif effectiveAlign == alignItems_STRETCH then @@ -638,7 +812,11 @@ function LayoutEngine:layoutChildren() end -- Advance position by child's border-box height plus margins - currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing + currentMainPos = currentMainPos + + child:getBorderBoxHeight() + + child.margin.top + + child.margin.bottom + + itemSpacing end end @@ -925,8 +1103,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved if self.element.units.width.unit ~= "px" and self.element.units.width.unit ~= "auto" then local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth - self.element._borderBoxWidth = - Units.resolve(self.element.units.width.value, self.element.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) + self.element._borderBoxWidth = Units.resolve( + self.element.units.width.value, + self.element.units.width.unit, + newViewportWidth, + newViewportHeight, + parentWidth + ) elseif self.element.units.width.unit == "px" and self.element.units.width.value and self._Context.baseScale then -- Reapply base scaling to pixel widths (border-box) self.element._borderBoxWidth = self.element.units.width.value * scaleX @@ -936,8 +1119,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved if self.element.units.height.unit ~= "px" and self.element.units.height.unit ~= "auto" then local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight - self.element._borderBoxHeight = - Units.resolve(self.element.units.height.value, self.element.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) + self.element._borderBoxHeight = Units.resolve( + self.element.units.height.value, + self.element.units.height.unit, + newViewportWidth, + newViewportHeight, + parentHeight + ) elseif self.element.units.height.unit == "px" and self.element.units.height.value and self._Context.baseScale then -- Reapply base scaling to pixel heights (border-box) self.element._borderBoxHeight = self.element.units.height.value * scaleY @@ -947,13 +1135,20 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) if self.element.units.x.unit ~= "px" then local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth local baseX = self.element.parent and self.element.parent.x or 0 - local offsetX = Units.resolve(self.element.units.x.value, self.element.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) + local offsetX = Units.resolve( + self.element.units.x.value, + self.element.units.x.unit, + newViewportWidth, + newViewportHeight, + parentWidth + ) self.element.x = baseX + offsetX else -- For pixel units, update position relative to parent's new position (with base scaling) if self.element.parent then local baseX = self.element.parent.x - local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX) or self.element.units.x.value + local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX) + or self.element.units.x.value self.element.x = baseX + scaledOffset elseif self._Context.baseScale then -- Top-level element with pixel position - apply base scaling @@ -964,13 +1159,20 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) if self.element.units.y.unit ~= "px" then local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight local baseY = self.element.parent and self.element.parent.y or 0 - local offsetY = Units.resolve(self.element.units.y.value, self.element.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) + local offsetY = Units.resolve( + self.element.units.y.value, + self.element.units.y.unit, + newViewportWidth, + newViewportHeight, + parentHeight + ) self.element.y = baseY + offsetY else -- For pixel units, update position relative to parent's new position (with base scaling) if self.element.parent then local baseY = self.element.parent.y - local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY) or self.element.units.y.value + local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY) + or self.element.units.y.value self.element.y = baseY + scaledOffset elseif self._Context.baseScale then -- Top-level element with pixel position - apply base scaling @@ -1006,8 +1208,10 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) end -- Apply min/max constraints (with base scaling) - local minSize = self.element.minTextSize and (self._Context.baseScale and (self.element.minTextSize * scaleY) or self.element.minTextSize) - local maxSize = self.element.maxTextSize and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) + local minSize = self.element.minTextSize + and (self._Context.baseScale and (self.element.minTextSize * scaleY) or self.element.minTextSize) + local maxSize = self.element.maxTextSize + and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) if minSize and self.element.textSize < minSize then self.element.textSize = minSize @@ -1037,17 +1241,43 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) -- Recalculate gap if using viewport or percentage units if self.element.units.gap.unit ~= "px" then - local containerSize = (self.flexDirection == self._FlexDirection.HORIZONTAL) and (self.element.parent and self.element.parent.width or newViewportWidth) + local containerSize = (self.flexDirection == self._FlexDirection.HORIZONTAL) + and (self.element.parent and self.element.parent.width or newViewportWidth) or (self.element.parent and self.element.parent.height or newViewportHeight) - self.element.gap = Units.resolve(self.element.units.gap.value, self.element.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) + self.element.gap = Units.resolve( + self.element.units.gap.value, + self.element.units.gap.unit, + newViewportWidth, + newViewportHeight, + containerSize + ) + end + + -- Recalculate flexBasis if using viewport or percentage units + if + self.element.units.flexBasis + and self.element.units.flexBasis.unit ~= "auto" + and self.element.units.flexBasis.unit ~= "px" + then + local value, unit = self.element.units.flexBasis.value, self.element.units.flexBasis.unit + -- flexBasis uses parent dimensions for % (main axis determines which dimension) + local parentSize = self.element.parent and self.element.parent.width or newViewportWidth + local resolvedBasis = Units.resolve(value, unit, newViewportWidth, newViewportHeight, parentSize) + if type(resolvedBasis) == "number" then + self.element.flexBasis = resolvedBasis + end end -- Recalculate spacing (padding/margin) if using viewport or percentage units -- For percentage-based padding: -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) - local parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth or self.element._borderBoxWidth or newViewportWidth - local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight or self.element._borderBoxHeight or newViewportHeight + local parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth + or self.element._borderBoxWidth + or newViewportWidth + local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight + or self.element._borderBoxHeight + or newViewportHeight -- Handle shorthand properties first (horizontal/vertical) local resolvedHorizontalPadding = nil @@ -1099,8 +1329,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) elseif self.element.units.padding[side].unit ~= "px" then -- Recalculate non-pixel units local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.element.padding[side] = - Units.resolve(self.element.units.padding[side].value, self.element.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) + self.element.padding[side] = Units.resolve( + self.element.units.padding[side].value, + self.element.units.padding[side].unit, + newViewportWidth, + newViewportHeight, + parentSize + ) end -- If unit is "px" and not using shorthand, value stays the same end @@ -1156,8 +1391,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) elseif self.element.units.margin[side].unit ~= "px" then -- Recalculate non-pixel units local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.element.margin[side] = - Units.resolve(self.element.units.margin[side].value, self.element.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) + self.element.margin[side] = Units.resolve( + self.element.units.margin[side].value, + self.element.units.margin[side].unit, + newViewportWidth, + newViewportHeight, + parentSize + ) end -- If unit is "px" and not using shorthand, value stays the same end @@ -1169,7 +1409,8 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) if self.element.units.width.unit ~= "auto" and self.element.units.width.unit ~= "px" then -- _borderBoxWidth was recalculated for viewport/percentage units -- Calculate content width by subtracting padding - self.element.width = math.max(0, self.element._borderBoxWidth - self.element.padding.left - self.element.padding.right) + self.element.width = + math.max(0, self.element._borderBoxWidth - self.element.padding.left - self.element.padding.right) elseif self.element.units.width.unit == "auto" then -- For auto-sized elements, width is content width (calculated in resize method) -- Update border-box to include padding @@ -1180,7 +1421,8 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) if self.element.units.height.unit ~= "auto" and self.element.units.height.unit ~= "px" then -- _borderBoxHeight was recalculated for viewport/percentage units -- Calculate content height by subtracting padding - self.element.height = math.max(0, self.element._borderBoxHeight - self.element.padding.top - self.element.padding.bottom) + self.element.height = + math.max(0, self.element._borderBoxHeight - self.element.padding.top - self.element.padding.bottom) elseif self.element.units.height.unit == "auto" then -- For auto-sized elements, height is content height (calculated in resize method) -- Update border-box to include padding