Files
FlexLove/modules/LayoutEngine.lua
2025-12-05 11:52:30 -05:00

1233 lines
51 KiB
Lua

---@class LayoutEngine
---@field element Element? Reference to the parent element
---@field positioning Positioning Layout positioning mode
---@field flexDirection FlexDirection Direction of flex layout
---@field justifyContent JustifyContent Alignment of items along main axis
---@field alignItems AlignItems Alignment of items along cross axis
---@field alignContent AlignContent Alignment of lines in multi-line flex containers
---@field flexWrap FlexWrap Whether children wrap to multiple lines
---@field gap number Space between children elements
---@field gridRows number? Number of rows in the grid
---@field gridColumns number? Number of columns in the grid
---@field columnGap number? Gap between grid columns
---@field rowGap number? Gap between grid rows
---@field _Grid table
---@field _Units table
---@field _Context table
---@field _Positioning table
---@field _FlexDirection table
---@field _JustifyContent table
---@field _AlignContent table
---@field _AlignItems table
---@field _AlignSelf table
---@field _FlexWrap table
---@field _layoutCount number Track layout recalculations per frame
---@field _lastFrameCount number Last frame number for resetting counters
---@field _ErrorHandler ErrorHandler? ErrorHandler module dependency
---@field _Performance Performance? Performance module dependency
local LayoutEngine = {}
LayoutEngine.__index = LayoutEngine
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler, Performance}
function LayoutEngine.init(deps)
LayoutEngine._ErrorHandler = deps.ErrorHandler
LayoutEngine._Performance = deps.Performance
end
---@class LayoutEngineProps
---@field positioning Positioning? Layout positioning mode (default: RELATIVE)
---@field flexDirection FlexDirection? Direction of flex layout (default: HORIZONTAL)
---@field justifyContent JustifyContent? Alignment of items along main axis (default: FLEX_START)
---@field alignItems AlignItems? Alignment of items along cross axis (default: STRETCH)
---@field alignContent AlignContent? Alignment of lines in multi-line flex containers (default: STRETCH)
---@field flexWrap FlexWrap? Whether children wrap to multiple lines (default: NOWRAP)
---@field gap number? Space between children elements (default: 10)
---@field gridRows number? Number of rows in the grid
---@field gridColumns number? Number of columns in the grid
---@field columnGap number? Gap between grid columns
---@field rowGap number? Gap between grid rows
--- Create a new LayoutEngine instance
---@param props LayoutEngineProps
---@param deps table Dependencies {utils, Grid, Units, Context}
---@return LayoutEngine
function LayoutEngine.new(props, deps)
local enums = deps.utils.enums
local Positioning = enums.Positioning
local FlexDirection = enums.FlexDirection
local JustifyContent = enums.JustifyContent
local AlignContent = enums.AlignContent
local AlignItems = enums.AlignItems
local AlignSelf = enums.AlignSelf
local FlexWrap = enums.FlexWrap
local self = setmetatable({}, LayoutEngine)
-- Store dependencies for instance methods
self._Grid = deps.Grid
self._Units = deps.Units
self._Context = deps.Context
self._ErrorHandler = deps.ErrorHandler
self._Positioning = Positioning
self._FlexDirection = FlexDirection
self._JustifyContent = JustifyContent
self._AlignContent = AlignContent
self._AlignItems = AlignItems
self._AlignSelf = AlignSelf
self._FlexWrap = FlexWrap
-- Layout configuration
self.positioning = props.positioning or Positioning.FLEX
self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL
self.justifyContent = props.justifyContent or JustifyContent.FLEX_START
self.alignItems = props.alignItems or AlignItems.STRETCH
self.alignContent = props.alignContent or AlignContent.STRETCH
self.flexWrap = props.flexWrap or FlexWrap.NOWRAP
self.gap = props.gap or 10
-- Grid layout configuration
self.gridRows = props.gridRows
self.gridColumns = props.gridColumns
self.columnGap = props.columnGap
self.rowGap = props.rowGap
-- Element reference (will be set via initialize)
self.element = nil
-- Performance tracking
self._layoutCount = 0
self._lastFrameCount = 0
-- Layout memoization cache
self._layoutCache = {
childrenCount = 0,
containerWidth = 0,
containerHeight = 0,
childrenHash = "",
}
return self
end
--- Initialize the LayoutEngine with its parent element
---@param element Element The parent element
function LayoutEngine:initialize(element)
self.element = element
end
--- Apply CSS positioning offsets (top, right, bottom, left) to a child element
---@param child Element The element to apply offsets to
function LayoutEngine:applyPositioningOffsets(child)
if not child then
return
end
-- For CSS-style positioning, we need the parent's bounds
local parent = child.parent
if not parent then
return
end
-- Only apply offsets to explicitly absolute children or children in relative/absolute containers
-- Flex/grid children ignore positioning offsets as they participate in layout
local isFlexChild = child.positioning == self._Positioning.FLEX
or child.positioning == self._Positioning.GRID
or (child.positioning == self._Positioning.ABSOLUTE and not child._explicitlyAbsolute)
if not isFlexChild then
-- Apply absolute positioning for explicitly absolute children
-- Apply top offset (distance from parent's content box top edge)
if child.top then
child.y = parent.y + parent.padding.top + child.top
end
-- Apply bottom offset (distance from parent's content box bottom edge)
-- BORDER-BOX MODEL: Use border-box dimensions for positioning
if child.bottom then
local elementBorderBoxHeight = child:getBorderBoxHeight()
child.y = parent.y + parent.padding.top + parent.height - child.bottom - elementBorderBoxHeight
end
-- Apply left offset (distance from parent's content box left edge)
if child.left then
child.x = parent.x + parent.padding.left + child.left
end
-- Apply right offset (distance from parent's content box right edge)
-- BORDER-BOX MODEL: Use border-box dimensions for positioning
if child.right then
local elementBorderBoxWidth = child:getBorderBoxWidth()
child.x = parent.x + parent.padding.left + parent.width - child.right - elementBorderBoxWidth
end
end
end
--- Layout children within this element according to positioning mode
function LayoutEngine:layoutChildren()
-- Start performance timing first (before any early returns)
local timerName = nil
if LayoutEngine._Performance and LayoutEngine._Performance.enabled and self.element then
-- Use memory address to make timer name unique per element instance
timerName = "layout_" .. (self.element.id or tostring(self.element):match("0x%x+") or "unknown")
LayoutEngine._Performance:startTimer(timerName)
end
if self.element == nil then
return
end
-- Check if layout can be skipped (memoization optimization)
if self:_canSkipLayout() then
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
return
end
-- Track layout recalculations for performance warnings
self:_trackLayoutRecalculation()
if self.positioning == self._Positioning.ABSOLUTE or self.positioning == self._Positioning.RELATIVE then
-- Absolute/Relative positioned containers don't layout their children according to flex rules,
-- but they should still apply CSS positioning offsets to their children
for _, child in ipairs(self.element.children) do
if child.top or child.right or child.bottom or child.left then
self:applyPositioningOffsets(child)
end
end
-- Detect overflow after children positioning
if self.element._detectOverflow then
self.element:_detectOverflow()
end
-- Stop performance timing
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
return
end
-- Handle grid layout
if self.positioning == self._Positioning.GRID then
self._Grid.layoutGridItems(self.element)
-- Stop performance timing
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
return
end
local childCount = #self.element.children
if childCount == 0 then
-- Stop performance timing
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
return
end
-- Get flex children (children that participate in flex layout)
local flexChildren = {}
for _, child in ipairs(self.element.children) do
local isFlexChild = not (child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute)
if isFlexChild then
table.insert(flexChildren, child)
-- Warn if child uses percentage sizing but parent has autosizing
if child.units and child.units.width then
if child.units.width.unit == "%" and self.element.autosizing and self.element.autosizing.width then
LayoutEngine._ErrorHandler:warn("LayoutEngine", "LAY_004", {
child = child.id or "unnamed",
issue = "percentage width with parent auto-sizing",
})
end
end
if child.units and child.units.height then
if child.units.height.unit == "%" and self.element.autosizing and self.element.autosizing.height then
LayoutEngine._ErrorHandler:warn("LayoutEngine", "LAY_004", {
child = child.id or "unnamed",
issue = "percentage height with parent auto-sizing",
})
end
end
end
end
if #flexChildren == 0 then
return
end
-- Calculate space reserved by absolutely positioned siblings with explicit positioning
local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical)
local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical)
local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical)
local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical)
for _, child in ipairs(self.element.children) do
-- Only consider absolutely positioned children with explicit positioning
if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
if self.flexDirection == self._FlexDirection.HORIZONTAL then
-- Horizontal layout: main axis is X, cross axis is Y
-- Check for left positioning (reserves space at main axis start)
if child.left then
local spaceNeeded = child.left + childBorderBoxWidth
reservedMainStart = math.max(reservedMainStart, spaceNeeded)
end
-- Check for right positioning (reserves space at main axis end)
if child.right then
local spaceNeeded = child.right + childBorderBoxWidth
reservedMainEnd = math.max(reservedMainEnd, spaceNeeded)
end
-- Check for top positioning (reserves space at cross axis start)
if child.top then
local spaceNeeded = child.top + childBorderBoxHeight
reservedCrossStart = math.max(reservedCrossStart, spaceNeeded)
end
-- Check for bottom positioning (reserves space at cross axis end)
if child.bottom then
local spaceNeeded = child.bottom + childBorderBoxHeight
reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded)
end
else
-- Vertical layout: main axis is Y, cross axis is X
-- Check for top positioning (reserves space at main axis start)
if child.top then
local spaceNeeded = child.top + childBorderBoxHeight
reservedMainStart = math.max(reservedMainStart, spaceNeeded)
end
-- Check for bottom positioning (reserves space at main axis end)
if child.bottom then
local spaceNeeded = child.bottom + childBorderBoxHeight
reservedMainEnd = math.max(reservedMainEnd, spaceNeeded)
end
-- Check for left positioning (reserves space at cross axis start)
if child.left then
local spaceNeeded = child.left + childBorderBoxWidth
reservedCrossStart = math.max(reservedCrossStart, spaceNeeded)
end
-- Check for right positioning (reserves space at cross axis end)
if child.right then
local spaceNeeded = child.right + childBorderBoxWidth
reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded)
end
end
end
end
-- Calculate available space (accounting for padding and reserved space)
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions (padding subtracted)
local availableMainSize = 0
local availableCrossSize = 0
if self.flexDirection == self._FlexDirection.HORIZONTAL then
availableMainSize = self.element.width - reservedMainStart - reservedMainEnd
availableCrossSize = self.element.height - reservedCrossStart - reservedCrossEnd
else
availableMainSize = self.element.height - reservedMainStart - reservedMainEnd
availableCrossSize = self.element.width - reservedCrossStart - reservedCrossEnd
end
-- Handle flex wrap: create lines of children
local lines = {}
if self.flexWrap == self._FlexWrap.NOWRAP then
-- All children go on one line
lines[1] = flexChildren
else
-- Wrap children into multiple lines
local currentLine = {}
local currentLineSize = 0
for _, child in ipairs(flexChildren) do
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
-- Include margins in size calculations
local childMainSize = 0
local childMainMargin = 0
if self.flexDirection == self._FlexDirection.HORIZONTAL then
childMainSize = child:getBorderBoxWidth()
childMainMargin = child.margin.left + child.margin.right
else
childMainSize = child:getBorderBoxHeight()
childMainMargin = child.margin.top + child.margin.bottom
end
local childTotalMainSize = childMainSize + childMainMargin
-- Check if adding this child would exceed the available space
local lineSpacing = #currentLine > 0 and self.gap or 0
if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then
-- Start a new line
if #currentLine > 0 then
table.insert(lines, currentLine)
end
currentLine = { child }
currentLineSize = childTotalMainSize
else
-- Add to current line
table.insert(currentLine, child)
currentLineSize = currentLineSize + lineSpacing + childTotalMainSize
end
end
-- Add the last line if it has children
if #currentLine > 0 then
table.insert(lines, currentLine)
end
-- Handle wrap-reverse: reverse the order of lines
if self.flexWrap == self._FlexWrap.WRAP_REVERSE then
local reversedLines = {}
for i = #lines, 1, -1 do
table.insert(reversedLines, lines[i])
end
lines = reversedLines
end
end
-- Calculate line positions and heights (including child padding)
local lineHeights = {}
local totalLinesHeight = 0
for lineIndex, line in ipairs(lines) do
local maxCrossSize = 0
for _, child in ipairs(line) do
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
-- Include margins in cross-axis size calculations
local childCrossSize = 0
local childCrossMargin = 0
if self.flexDirection == self._FlexDirection.HORIZONTAL then
childCrossSize = child:getBorderBoxHeight()
childCrossMargin = child.margin.top + child.margin.bottom
else
childCrossSize = child:getBorderBoxWidth()
childCrossMargin = child.margin.left + child.margin.right
end
local childTotalCrossSize = childCrossSize + childCrossMargin
maxCrossSize = math.max(maxCrossSize, childTotalCrossSize)
end
lineHeights[lineIndex] = maxCrossSize
totalLinesHeight = totalLinesHeight + maxCrossSize
end
-- Account for gaps between lines
local lineGaps = math.max(0, #lines - 1) * self.gap
totalLinesHeight = totalLinesHeight + lineGaps
-- 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
-- STRETCH, CENTER, and FLEX_END should use full available cross size
lineHeights[1] = availableCrossSize
totalLinesHeight = availableCrossSize
end
-- CENTER and FLEX_END should preserve natural child dimensions
-- and only affect positioning within the available space
end
-- Calculate starting position for lines based on alignContent
local lineStartPos = 0
local lineSpacing = self.gap
local freeLineSpace = availableCrossSize - totalLinesHeight
-- Apply AlignContent logic for both single and multiple lines
if self.alignContent == self._AlignContent.FLEX_START then
lineStartPos = 0
elseif self.alignContent == self._AlignContent.CENTER then
lineStartPos = freeLineSpace / 2
elseif self.alignContent == self._AlignContent.FLEX_END then
lineStartPos = freeLineSpace
elseif self.alignContent == self._AlignContent.SPACE_BETWEEN then
lineStartPos = 0
if #lines > 1 then
lineSpacing = self.gap + (freeLineSpace / (#lines - 1))
end
elseif self.alignContent == self._AlignContent.SPACE_AROUND then
local spaceAroundEach = freeLineSpace / #lines
lineStartPos = spaceAroundEach / 2
lineSpacing = self.gap + spaceAroundEach
elseif self.alignContent == self._AlignContent.STRETCH then
lineStartPos = 0
if #lines > 1 and freeLineSpace > 0 then
lineSpacing = self.gap + (freeLineSpace / #lines)
-- Distribute extra space to line heights (only if positive)
local extraPerLine = freeLineSpace / #lines
for i = 1, #lineHeights do
lineHeights[i] = lineHeights[i] + extraPerLine
end
end
end
-- Position children within each line
local currentCrossPos = lineStartPos
for lineIndex, line in ipairs(lines) do
local lineHeight = lineHeights[lineIndex]
-- Calculate total size of children in this line (including padding and margins)
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
local totalChildrenSize = 0
for _, child in ipairs(line) do
if self.flexDirection == self._FlexDirection.HORIZONTAL then
totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right
else
totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom
end
end
local totalGapSize = math.max(0, #line - 1) * self.gap
local totalContentSize = totalChildrenSize + totalGapSize
local freeSpace = availableMainSize - totalContentSize
-- Calculate initial position and spacing based on justifyContent
local startPos = 0
local itemSpacing = self.gap
if self.justifyContent == self._JustifyContent.FLEX_START then
startPos = 0
elseif self.justifyContent == self._JustifyContent.CENTER then
startPos = freeSpace / 2
elseif self.justifyContent == self._JustifyContent.FLEX_END then
startPos = freeSpace
elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then
startPos = 0
if #line > 1 then
itemSpacing = self.gap + (freeSpace / (#line - 1))
end
elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then
local spaceAroundEach = freeSpace / #line
startPos = spaceAroundEach / 2
itemSpacing = self.gap + spaceAroundEach
elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then
local spaceBetween = freeSpace / (#line + 1)
startPos = spaceBetween
itemSpacing = self.gap + spaceBetween
end
-- Position children in this line
local currentMainPos = startPos
for _, child in ipairs(line) do
-- Determine effective cross-axis alignment
local effectiveAlign = child.alignSelf
if effectiveAlign == nil or effectiveAlign == self._AlignSelf.AUTO then
effectiveAlign = self.alignItems
end
if self.flexDirection == self._FlexDirection.HORIZONTAL then
-- Horizontal layout: main axis is X, cross axis is Y
-- Position child at border box (x, y represents top-left including padding)
-- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins
child.x = self.element.x + self.element.padding.left + reservedMainStart + currentMainPos + child.margin.left
-- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations
local childBorderBoxHeight = child:getBorderBoxHeight()
local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom
if effectiveAlign == self._AlignItems.FLEX_START then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top
elseif effectiveAlign == self._AlignItems.CENTER then
child.y = self.element.y
+ self.element.padding.top
+ reservedCrossStart
+ currentCrossPos
+ ((lineHeight - childTotalCrossSize) / 2)
+ child.margin.top
elseif effectiveAlign == self._AlignItems.FLEX_END then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top
elseif effectiveAlign == self._AlignItems.STRETCH then
-- STRETCH: Only apply if height was not explicitly set
if child.autosizing and child.autosizing.height then
-- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit
local availableHeight = lineHeight - child.margin.top - child.margin.bottom
child._borderBoxHeight = availableHeight
child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom)
end
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top
end
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, re-layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
-- Advance position by child's border-box width plus margins
currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing
else
-- Vertical layout: main axis is Y, cross axis is X
-- Position child at border box (x, y represents top-left including padding)
-- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins
child.y = self.element.y + self.element.padding.top + reservedMainStart + currentMainPos + child.margin.top
-- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right
if effectiveAlign == self._AlignItems.FLEX_START then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left
elseif effectiveAlign == self._AlignItems.CENTER then
child.x = self.element.x
+ self.element.padding.left
+ reservedCrossStart
+ currentCrossPos
+ ((lineHeight - childTotalCrossSize) / 2)
+ child.margin.left
elseif effectiveAlign == self._AlignItems.FLEX_END then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left
elseif effectiveAlign == self._AlignItems.STRETCH then
-- STRETCH: Only apply if width was not explicitly set
if child.autosizing and child.autosizing.width then
-- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit
local availableWidth = lineHeight - child.margin.left - child.margin.right
child._borderBoxWidth = availableWidth
child.width = math.max(0, availableWidth - child.padding.left - child.padding.right)
end
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left
end
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, re-layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
-- Advance position by child's border-box height plus margins
currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing
end
end
-- Move to next line position
currentCrossPos = currentCrossPos + lineHeight + lineSpacing
end
-- Position explicitly absolute children after flex layout
for _, child in ipairs(self.element.children) do
if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute then
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
end
end
-- Detect overflow after children are laid out
if self.element._detectOverflow then
self.element:_detectOverflow()
end
-- Stop performance timing
if timerName and LayoutEngine._Performance then
LayoutEngine._Performance:stopTimer(timerName)
end
end
--- Simulate wrapping children into lines for auto-sizing calculations
---@param children table Array of child elements
---@param availableSize number Available space in main axis
---@param isHorizontal boolean True if flex direction is horizontal
---@return table Array of lines, where each line is an array of children
function LayoutEngine:_simulateWrap(children, availableSize, isHorizontal)
local lines = {}
local currentLine = {}
local currentLineSize = 0
for _, child in ipairs(children) do
-- Calculate child size in main axis (including margins)
local childMainSize = 0
local childMainMargin = 0
if isHorizontal then
childMainSize = child:getBorderBoxWidth()
if child.margin then
childMainMargin = child.margin.left + child.margin.right
end
else
childMainSize = child:getBorderBoxHeight()
if child.margin then
childMainMargin = child.margin.top + child.margin.bottom
end
end
local childTotalMainSize = childMainSize + childMainMargin
-- Check if adding this child would exceed the available space
local lineSpacing = #currentLine > 0 and self.gap or 0
if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableSize then
-- Start a new line
table.insert(lines, currentLine)
currentLine = { child }
currentLineSize = childTotalMainSize
else
-- Add to current line
table.insert(currentLine, child)
currentLineSize = currentLineSize + lineSpacing + childTotalMainSize
end
end
-- Add the last line if it has children
if #currentLine > 0 then
table.insert(lines, currentLine)
end
return lines
end
--- Calculate auto width based on children
---@return number
function LayoutEngine:calculateAutoWidth()
if self.element == nil then
return 0
end
-- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box
local contentWidth = self.element:calculateTextWidth()
if not self.element.children or #self.element.children == 0 then
return contentWidth
end
-- Get flex children (children that participate in flex layout)
local flexChildren = {}
for _, child in ipairs(self.element.children) do
if not child._explicitlyAbsolute then
table.insert(flexChildren, child)
end
end
if #flexChildren == 0 then
return contentWidth
end
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
if isHorizontal then
-- HORIZONTAL flex with potential wrapping
if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then
-- Container has explicit width and wrapping enabled - calculate based on wrapped lines
local availableWidth = self.element.width
local lines = self:_simulateWrap(flexChildren, availableWidth, true)
-- Find the widest line
local maxLineWidth = contentWidth
for _, line in ipairs(lines) do
local lineWidth = 0
for i, child in ipairs(line) do
local childBorderBoxWidth = child:getBorderBoxWidth()
local childMarginH = 0
if child.margin then
childMarginH = child.margin.left + child.margin.right
end
lineWidth = lineWidth + childBorderBoxWidth + childMarginH
if i < #line then
lineWidth = lineWidth + self.gap
end
end
maxLineWidth = math.max(maxLineWidth, lineWidth)
end
return maxLineWidth
else
-- No wrapping or no explicit width - sum all children on one line
local totalWidth = contentWidth
for i, child in ipairs(flexChildren) do
local childBorderBoxWidth = child:getBorderBoxWidth()
local childMarginH = 0
if child.margin then
childMarginH = child.margin.left + child.margin.right
end
totalWidth = totalWidth + childBorderBoxWidth + childMarginH
if i < #flexChildren then
totalWidth = totalWidth + self.gap
end
end
return totalWidth
end
else
-- VERTICAL flex - return max child width (including margins)
local maxWidth = contentWidth
for _, child in ipairs(flexChildren) do
local childBorderBoxWidth = child:getBorderBoxWidth()
local childMarginH = 0
if child.margin then
childMarginH = child.margin.left + child.margin.right
end
maxWidth = math.max(maxWidth, childBorderBoxWidth + childMarginH)
end
return maxWidth
end
end
---@return number
function LayoutEngine:calculateAutoHeight()
if self.element == nil then
return 0
end
local height = self.element:calculateTextHeight()
if not self.element.children or #self.element.children == 0 then
return height
end
-- Get flex children (children that participate in flex layout)
local flexChildren = {}
for _, child in ipairs(self.element.children) do
if not child._explicitlyAbsolute then
table.insert(flexChildren, child)
end
end
if #flexChildren == 0 then
return height
end
local isVertical = self.flexDirection == self._FlexDirection.VERTICAL
if isVertical then
-- VERTICAL flex with potential wrapping
if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.height and self.element.height > 0 then
-- Container has explicit height and wrapping enabled - calculate based on wrapped lines
local availableHeight = self.element.height
local lines = self:_simulateWrap(flexChildren, availableHeight, false)
-- Sum all line heights
local totalLinesHeight = height
for i, line in ipairs(lines) do
local lineHeight = 0
for _, child in ipairs(line) do
local childBorderBoxHeight = child:getBorderBoxHeight()
local childMarginV = 0
if child.margin then
childMarginV = child.margin.top + child.margin.bottom
end
lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV)
end
totalLinesHeight = totalLinesHeight + lineHeight
if i < #lines then
totalLinesHeight = totalLinesHeight + self.gap
end
end
return totalLinesHeight
else
-- No wrapping or no explicit height - sum all children on one line
local totalHeight = height
for i, child in ipairs(flexChildren) do
local childBorderBoxHeight = child:getBorderBoxHeight()
local childMarginV = 0
if child.margin then
childMarginV = child.margin.top + child.margin.bottom
end
totalHeight = totalHeight + childBorderBoxHeight + childMarginV
if i < #flexChildren then
totalHeight = totalHeight + self.gap
end
end
return totalHeight
end
else
-- HORIZONTAL flex with potential wrapping
if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then
-- Container has explicit width and wrapping enabled - calculate based on wrapped lines
local availableWidth = self.element.width
local lines = self:_simulateWrap(flexChildren, availableWidth, true)
-- Sum all line heights (cross-axis for horizontal flex)
local totalLinesHeight = height
for i, line in ipairs(lines) do
local lineHeight = 0
for _, child in ipairs(line) do
local childBorderBoxHeight = child:getBorderBoxHeight()
local childMarginV = 0
if child.margin then
childMarginV = child.margin.top + child.margin.bottom
end
lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV)
end
totalLinesHeight = totalLinesHeight + lineHeight
if i < #lines then
totalLinesHeight = totalLinesHeight + self.gap
end
end
return totalLinesHeight
else
-- No wrapping or no explicit width - return max child height (including margins)
local maxHeight = height
for _, child in ipairs(flexChildren) do
local childBorderBoxHeight = child:getBorderBoxHeight()
local childMarginV = 0
if child.margin then
childMarginV = child.margin.top + child.margin.bottom
end
maxHeight = math.max(maxHeight, childBorderBoxHeight + childMarginV)
end
return maxHeight
end
end
end
--- Recalculate units based on new viewport dimensions (for vw, vh, % units)
---@param newViewportWidth number
---@param newViewportHeight number
function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
if self.element == nil then
return
end
local Units = self._Units
-- Get updated scale factors
local scaleX, scaleY = self._Context.getScaleFactors()
-- Recalculate border-box width if using viewport or percentage units (skip auto-sized)
-- 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)
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
end
-- Recalculate border-box height if using viewport or percentage units (skip auto-sized)
-- 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)
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
end
-- Recalculate position if using viewport or percentage units
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)
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
self.element.x = baseX + scaledOffset
elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling
self.element.x = self.element.units.x.value * scaleX
end
end
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)
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
self.element.y = baseY + scaledOffset
elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling
self.element.y = self.element.units.y.value * scaleY
end
end
-- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units
if self.element.autoScaleText and self.element.units.textSize.value then
local unit = self.element.units.textSize.unit
local value = self.element.units.textSize.value
if unit == "px" and self._Context.baseScale then
-- With base scaling: scale pixel values relative to base resolution
self.element.textSize = value * scaleY
elseif unit == "px" then
-- Without base scaling but auto-scaling enabled: text doesn't scale
self.element.textSize = value
elseif unit == "%" or unit == "vh" then
-- Percentage and vh are relative to viewport height
self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight)
elseif unit == "vw" then
-- vw is relative to viewport width
self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth)
elseif unit == "ew" then
-- Element width relative
self.element.textSize = (value / 100) * self.element.width
elseif unit == "eh" then
-- Element height relative
self.element.textSize = (value / 100) * self.element.height
else
self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil)
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)
if minSize and self.element.textSize < minSize then
self.element.textSize = minSize
end
if maxSize and self.element.textSize > maxSize then
self.element.textSize = maxSize
end
-- Protect against too-small text sizes (minimum 1px)
if self.element.textSize < 1 then
self.element.textSize = 1 -- Minimum 1px
end
elseif self.element.units.textSize.unit == "px" and self.element.units.textSize.value and self._Context.baseScale then
-- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes
self.element.textSize = self.element.units.textSize.value * scaleY
-- Protect against too-small text sizes (minimum 1px)
if self.element.textSize < 1 then
self.element.textSize = 1 -- Minimum 1px
end
end
-- Final protection: ensure textSize is always at least 1px (catches all edge cases)
if self.element.text and self.element.textSize and self.element.textSize < 1 then
self.element.textSize = 1 -- Minimum 1px
end
-- 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)
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)
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
-- Handle shorthand properties first (horizontal/vertical)
local resolvedHorizontalPadding = nil
local resolvedVerticalPadding = nil
if self.element.units.padding.horizontal and self.element.units.padding.horizontal.unit ~= "px" then
resolvedHorizontalPadding = Units.resolve(
self.element.units.padding.horizontal.value,
self.element.units.padding.horizontal.unit,
newViewportWidth,
newViewportHeight,
parentBorderBoxWidth
)
elseif self.element.units.padding.horizontal and self.element.units.padding.horizontal.value then
resolvedHorizontalPadding = self.element.units.padding.horizontal.value
end
if self.element.units.padding.vertical and self.element.units.padding.vertical.unit ~= "px" then
resolvedVerticalPadding = Units.resolve(
self.element.units.padding.vertical.value,
self.element.units.padding.vertical.unit,
newViewportWidth,
newViewportHeight,
parentBorderBoxHeight
)
elseif self.element.units.padding.vertical and self.element.units.padding.vertical.value then
resolvedVerticalPadding = self.element.units.padding.vertical.value
end
-- Resolve individual padding sides (with fallback to shorthand)
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
-- Check if this side was explicitly set or if we should use shorthand
local useShorthand = false
if not self.element.units.padding[side].explicit then
-- Not explicitly set, check if we have shorthand
if side == "left" or side == "right" then
useShorthand = resolvedHorizontalPadding ~= nil
elseif side == "top" or side == "bottom" then
useShorthand = resolvedVerticalPadding ~= nil
end
end
if useShorthand then
-- Use shorthand value
if side == "left" or side == "right" then
self.element.padding[side] = resolvedHorizontalPadding
else
self.element.padding[side] = resolvedVerticalPadding
end
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)
end
-- If unit is "px" and not using shorthand, value stays the same
end
-- Handle margin shorthand properties
local resolvedHorizontalMargin = nil
local resolvedVerticalMargin = nil
if self.element.units.margin.horizontal and self.element.units.margin.horizontal.unit ~= "px" then
resolvedHorizontalMargin = Units.resolve(
self.element.units.margin.horizontal.value,
self.element.units.margin.horizontal.unit,
newViewportWidth,
newViewportHeight,
parentBorderBoxWidth
)
elseif self.element.units.margin.horizontal and self.element.units.margin.horizontal.value then
resolvedHorizontalMargin = self.element.units.margin.horizontal.value
end
if self.element.units.margin.vertical and self.element.units.margin.vertical.unit ~= "px" then
resolvedVerticalMargin = Units.resolve(
self.element.units.margin.vertical.value,
self.element.units.margin.vertical.unit,
newViewportWidth,
newViewportHeight,
parentBorderBoxHeight
)
elseif self.element.units.margin.vertical and self.element.units.margin.vertical.value then
resolvedVerticalMargin = self.element.units.margin.vertical.value
end
-- Resolve individual margin sides (with fallback to shorthand)
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
-- Check if this side was explicitly set or if we should use shorthand
local useShorthand = false
if not self.element.units.margin[side].explicit then
-- Not explicitly set, check if we have shorthand
if side == "left" or side == "right" then
useShorthand = resolvedHorizontalMargin ~= nil
elseif side == "top" or side == "bottom" then
useShorthand = resolvedVerticalMargin ~= nil
end
end
if useShorthand then
-- Use shorthand value
if side == "left" or side == "right" then
self.element.margin[side] = resolvedHorizontalMargin
else
self.element.margin[side] = resolvedVerticalMargin
end
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)
end
-- If unit is "px" and not using shorthand, value stays the same
end
-- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions
-- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier
-- Now we calculate content width/height by subtracting padding
-- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed)
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)
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
self.element._borderBoxWidth = self.element.width + self.element.padding.left + self.element.padding.right
end
-- For pixel units, width stays as-is (may have been manually modified)
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)
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
self.element._borderBoxHeight = self.element.height + self.element.padding.top + self.element.padding.bottom
end
-- For pixel units, height stays as-is (may have been manually modified)
-- Detect overflow after layout calculations
if self.element._detectOverflow then
self.element:_detectOverflow()
end
end
--- Check if layout can be skipped based on cached state (memoization)
---@return boolean canSkip True if layout hasn't changed and can be skipped
function LayoutEngine:_canSkipLayout()
if not self.element then
return false
end
local childrenCount = #self.element.children
local containerWidth = self.element.width
local containerHeight = self.element.height
-- Generate simple hash of children dimensions
local childrenHash = ""
for i, child in ipairs(self.element.children) do
if i <= 5 then -- Only hash first 5 children for performance
childrenHash = childrenHash .. child.width .. "x" .. child.height .. ","
end
end
local cache = self._layoutCache
-- Check if layout inputs have changed
if
cache.childrenCount == childrenCount
and cache.containerWidth == containerWidth
and cache.containerHeight == containerHeight
and cache.childrenHash == childrenHash
then
return true -- Layout hasn't changed, can skip
end
-- Update cache with current values
cache.childrenCount = childrenCount
cache.containerWidth = containerWidth
cache.containerHeight = containerHeight
cache.childrenHash = childrenHash
return false -- Layout has changed, must recalculate
end
--- Track layout recalculations and warn about excessive layouts
function LayoutEngine:_trackLayoutRecalculation()
if not LayoutEngine._Performance or not LayoutEngine._Performance.warningsEnabled then
return
end
-- Get current frame count from Context
local currentFrame = self._Context and self._Context._frameNumber or 0
-- Reset counter on new frame
if currentFrame ~= self._lastFrameCount then
self._lastFrameCount = currentFrame
self._layoutCount = 0
end
-- Increment layout count
self._layoutCount = self._layoutCount + 1
-- Warn if layout is recalculated excessively this frame
if self._layoutCount >= 10 then
local elementId = self.element and self.element.id or "unnamed"
LayoutEngine._Performance:logWarning(
string.format("excessive_layout_%s", elementId),
"LayoutEngine",
string.format("Layout recalculated %d times this frame for element '%s'", self._layoutCount, elementId),
{ layoutCount = self._layoutCount, elementId = elementId },
"This may indicate a layout thrashing issue. Check for circular dependencies or dynamic sizing that triggers re-layout"
)
end
end
return LayoutEngine