feat: add flex grow/shrink

This commit is contained in:
Michael Freno
2026-01-05 11:28:04 -05:00
parent 121d787a0c
commit 157b932e80
2 changed files with 467 additions and 42 deletions

View File

@@ -32,6 +32,10 @@
---@field flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP) ---@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 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 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 textSize number? -- Resolved font size for text content in pixels
---@field minTextSize number? ---@field minTextSize number?
---@field maxTextSize number? ---@field maxTextSize number?
@@ -186,6 +190,75 @@ function Element.init(deps)
Element._Performance = deps.Performance Element._Performance = deps.Performance
end 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 ---@param props ElementProps
---@return Element ---@return Element
function Element.new(props) function Element.new(props)
@@ -571,7 +644,10 @@ function Element.new(props)
end end
else else
-- Store as table only if non-zero values exist -- 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 if hasNonZero then
self.cornerRadius = { self.cornerRadius = {
topLeft = props.cornerRadius.topLeft or 0, topLeft = props.cornerRadius.topLeft or 0,
@@ -612,7 +688,8 @@ function Element.new(props)
-- Validate objectFit -- Validate objectFit
if props.objectFit then 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") Element._utils.validateEnum(props.objectFit, validObjectFit, "objectFit")
end end
self.objectFit = props.objectFit or "fill" self.objectFit = props.objectFit or "fill"
@@ -784,6 +861,7 @@ function Element.new(props)
y = { value = nil, unit = "px" }, y = { value = nil, unit = "px" },
textSize = { value = nil, unit = "px" }, textSize = { value = nil, unit = "px" },
gap = { value = nil, unit = "px" }, gap = { value = nil, unit = "px" },
flexBasis = { value = nil, unit = "auto" },
padding = { padding = {
top = { value = nil, unit = "px" }, top = { value = nil, unit = "px" },
right = { value = nil, unit = "px" }, right = { value = nil, unit = "px" },
@@ -1017,6 +1095,82 @@ function Element.new(props)
self.units.gap = { value = 0, unit = "px" } self.units.gap = { value = 0, unit = "px" }
end 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 -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions
-- For explicit sizing, width/height already include padding (border-box) -- For explicit sizing, width/height already include padding (border-box)
@@ -1433,7 +1587,10 @@ function Element.new(props)
else else
-- Default: children in flex/grid containers participate in parent's layout -- Default: children in flex/grid containers participate in parent's layout
-- children in relative/absolute containers default to relative -- 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.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
self._explicitlyAbsolute = false -- Participate in parent's layout self._explicitlyAbsolute = false -- Participate in parent's layout
else else
@@ -2239,7 +2396,8 @@ function Element:getAvailableContentWidth()
-- Check if the element is using the scaled 9-patch contentPadding as its padding -- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels) -- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = ( 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 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 -- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels) -- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = ( 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 if not usingContentPaddingAsPadding then
@@ -2286,7 +2445,10 @@ function Element:addChild(child)
-- If child was created without explicit positioning, inherit from parent -- If child was created without explicit positioning, inherit from parent
if child._originalPositioning == nil then if child._originalPositioning == nil then
-- No explicit positioning was set during construction -- 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.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
child._explicitlyAbsolute = false -- Participate in parent's layout child._explicitlyAbsolute = false -- Participate in parent's layout
else else
@@ -2496,7 +2658,8 @@ function Element:draw(backdropCanvas)
if self.animation then if self.animation then
local anim = self.animation:interpolate() local anim = self.animation:interpolate()
if anim.opacity then 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
end end
@@ -2565,7 +2728,8 @@ function Element:draw(backdropCanvas)
-- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden) -- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden)
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY 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 -- Apply scroll offset if overflow is not visible
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) 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 -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) 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 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) -- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround)
local currentCanvas = love.graphics.getCanvas() 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 if self.contentBlur and self.contentBlur.radius > 0 and #sortedChildren > 0 then
local blurInstance = self:getBlurInstance() local blurInstance = self:getBlurInstance()
if blurInstance then 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 else
drawChildren() drawChildren()
end end
@@ -2826,7 +2999,12 @@ function Element:update(dt)
-- Check if we should handle scrollbar press for elements with overflow -- Check if we should handle scrollbar press for elements with overflow
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY 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 if hasScrollableOverflow and not self._scrollbarDragging then
-- Check for scrollbar press on left mouse button -- Check for scrollbar press on left mouse button
@@ -2918,7 +3096,8 @@ function Element:update(dt)
local anyPressed = self._eventHandler:isAnyButtonPressed() local anyPressed = self._eventHandler:isAnyButtonPressed()
-- Update theme state via ThemeManager -- 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 if self._stateId and self._elementMode == "immediate" then
local hover = newThemeState == "hover" local hover = newThemeState == "hover"
@@ -2995,8 +3174,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.width self.textSize = (value / 100) * self.width
-- Apply min/max constraints -- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) local minSize = self.minTextSize
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) 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 if minSize and self.textSize < minSize then
self.textSize = minSize self.textSize = minSize
end end
@@ -3011,8 +3192,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.height self.textSize = (value / 100) * self.height
-- Apply min/max constraints -- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) local minSize = self.minTextSize
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) 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 if minSize and self.textSize < minSize then
self.textSize = minSize self.textSize = minSize
end end

View File

@@ -222,6 +222,123 @@ function LayoutEngine:_batchCalculatePositions(children, startX, startY, spacing
return positions return positions
end 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 --- Layout children within this element according to positioning mode
function LayoutEngine:layoutChildren() function LayoutEngine:layoutChildren()
-- Start performance timing first (before any early returns) -- Start performance timing first (before any early returns)
@@ -398,13 +515,58 @@ function LayoutEngine:layoutChildren()
end end
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) -- Calculate line positions and heights (including child padding)
-- Performance optimization: preallocate array if possible -- Performance optimization: preallocate array if possible
local lineHeights = table.create and table.create(#lines) or {} local lineHeights = table.create and table.create(#lines) or {}
local totalLinesHeight = 0 local totalLinesHeight = 0
-- Performance optimization: hoist enum comparison outside loop -- Performance optimization: hoist enum comparison outside loop (already hoisted above)
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL -- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for lineIndex, line in ipairs(lines) do for lineIndex, line in ipairs(lines) do
local maxCrossSize = 0 local maxCrossSize = 0
@@ -435,7 +597,11 @@ function LayoutEngine:layoutChildren()
-- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size
if #lines == 1 then 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 -- STRETCH, CENTER, and FLEX_END should use full available cross size
lineHeights[1] = availableCrossSize lineHeights[1] = availableCrossSize
totalLinesHeight = availableCrossSize totalLinesHeight = availableCrossSize
@@ -574,7 +740,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then if effectiveAlign == alignItems_FLEX_START then
child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop
elseif effectiveAlign == alignItems_CENTER then 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 elseif effectiveAlign == alignItems_FLEX_END then
child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop
elseif effectiveAlign == alignItems_STRETCH then elseif effectiveAlign == alignItems_STRETCH then
@@ -615,7 +785,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then if effectiveAlign == alignItems_FLEX_START then
child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft
elseif effectiveAlign == alignItems_CENTER then 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 elseif effectiveAlign == alignItems_FLEX_END then
child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft
elseif effectiveAlign == alignItems_STRETCH then elseif effectiveAlign == alignItems_STRETCH then
@@ -638,7 +812,11 @@ function LayoutEngine:layoutChildren()
end end
-- Advance position by child's border-box height plus margins -- 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
end end
@@ -925,8 +1103,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved -- 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 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 local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth
self.element._borderBoxWidth = self.element._borderBoxWidth = Units.resolve(
Units.resolve(self.element.units.width.value, self.element.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) 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 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) -- Reapply base scaling to pixel widths (border-box)
self.element._borderBoxWidth = self.element.units.width.value * scaleX 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 -- 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 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 local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight
self.element._borderBoxHeight = self.element._borderBoxHeight = Units.resolve(
Units.resolve(self.element.units.height.value, self.element.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) 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 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) -- Reapply base scaling to pixel heights (border-box)
self.element._borderBoxHeight = self.element.units.height.value * scaleY 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 if self.element.units.x.unit ~= "px" then
local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth 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 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 self.element.x = baseX + offsetX
else else
-- For pixel units, update position relative to parent's new position (with base scaling) -- For pixel units, update position relative to parent's new position (with base scaling)
if self.element.parent then if self.element.parent then
local baseX = self.element.parent.x 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 self.element.x = baseX + scaledOffset
elseif self._Context.baseScale then elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling -- 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 if self.element.units.y.unit ~= "px" then
local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight 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 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 self.element.y = baseY + offsetY
else else
-- For pixel units, update position relative to parent's new position (with base scaling) -- For pixel units, update position relative to parent's new position (with base scaling)
if self.element.parent then if self.element.parent then
local baseY = self.element.parent.y 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 self.element.y = baseY + scaledOffset
elseif self._Context.baseScale then elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling -- Top-level element with pixel position - apply base scaling
@@ -1006,8 +1208,10 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end end
-- Apply min/max constraints (with base scaling) -- 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 minSize = self.element.minTextSize
local maxSize = self.element.maxTextSize and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) 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 if minSize and self.element.textSize < minSize then
self.element.textSize = minSize self.element.textSize = minSize
@@ -1037,17 +1241,43 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- Recalculate gap if using viewport or percentage units -- Recalculate gap if using viewport or percentage units
if self.element.units.gap.unit ~= "px" then 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) 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 end
-- Recalculate spacing (padding/margin) if using viewport or percentage units -- Recalculate spacing (padding/margin) if using viewport or percentage units
-- For percentage-based padding: -- For percentage-based padding:
-- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) -- - 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) -- - 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 parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth
local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight or self.element._borderBoxHeight or newViewportHeight 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) -- Handle shorthand properties first (horizontal/vertical)
local resolvedHorizontalPadding = nil local resolvedHorizontalPadding = nil
@@ -1099,8 +1329,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
elseif self.element.units.padding[side].unit ~= "px" then elseif self.element.units.padding[side].unit ~= "px" then
-- Recalculate non-pixel units -- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.element.padding[side] = self.element.padding[side] = Units.resolve(
Units.resolve(self.element.units.padding[side].value, self.element.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) self.element.units.padding[side].value,
self.element.units.padding[side].unit,
newViewportWidth,
newViewportHeight,
parentSize
)
end end
-- If unit is "px" and not using shorthand, value stays the same -- If unit is "px" and not using shorthand, value stays the same
end end
@@ -1156,8 +1391,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
elseif self.element.units.margin[side].unit ~= "px" then elseif self.element.units.margin[side].unit ~= "px" then
-- Recalculate non-pixel units -- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.element.margin[side] = self.element.margin[side] = Units.resolve(
Units.resolve(self.element.units.margin[side].value, self.element.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) self.element.units.margin[side].value,
self.element.units.margin[side].unit,
newViewportWidth,
newViewportHeight,
parentSize
)
end end
-- If unit is "px" and not using shorthand, value stays the same -- If unit is "px" and not using shorthand, value stays the same
end 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 if self.element.units.width.unit ~= "auto" and self.element.units.width.unit ~= "px" then
-- _borderBoxWidth was recalculated for viewport/percentage units -- _borderBoxWidth was recalculated for viewport/percentage units
-- Calculate content width by subtracting padding -- 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 elseif self.element.units.width.unit == "auto" then
-- For auto-sized elements, width is content width (calculated in resize method) -- For auto-sized elements, width is content width (calculated in resize method)
-- Update border-box to include padding -- 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 if self.element.units.height.unit ~= "auto" and self.element.units.height.unit ~= "px" then
-- _borderBoxHeight was recalculated for viewport/percentage units -- _borderBoxHeight was recalculated for viewport/percentage units
-- Calculate content height by subtracting padding -- 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 elseif self.element.units.height.unit == "auto" then
-- For auto-sized elements, height is content height (calculated in resize method) -- For auto-sized elements, height is content height (calculated in resize method)
-- Update border-box to include padding -- Update border-box to include padding