This commit is contained in:
Michael Freno
2025-11-12 17:53:22 -05:00
parent 7faa8b4ec6
commit 1293ca81a4
7 changed files with 3868 additions and 4611 deletions

View File

@@ -1,634 +0,0 @@
-- ====================
-- LayoutEngine Module
-- ====================
-- Extracted layout calculation functionality from Element.lua
-- Handles flexbox, grid, absolute/relative positioning, and auto-sizing
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local Grid = req("Grid")
local utils = req("utils")
-- Extract enum values
local enums = 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
---@class LayoutEngine
---@field element Element -- Reference to 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 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
local LayoutEngine = {}
LayoutEngine.__index = LayoutEngine
--- Create a new LayoutEngine instance
---@param config table -- Configuration options
---@return LayoutEngine
function LayoutEngine.new(config)
local self = setmetatable({}, LayoutEngine)
-- Store layout configuration
self.positioning = config.positioning or Positioning.RELATIVE
self.flexDirection = config.flexDirection or FlexDirection.HORIZONTAL
self.justifyContent = config.justifyContent or JustifyContent.FLEX_START
self.alignItems = config.alignItems or AlignItems.STRETCH
self.alignContent = config.alignContent or AlignContent.STRETCH
self.flexWrap = config.flexWrap or FlexWrap.NOWRAP
self.gridRows = config.gridRows
self.gridColumns = config.gridColumns
self.columnGap = config.columnGap
self.rowGap = config.rowGap
-- Element reference (set via initialize)
self.element = nil
return self
end
--- Initialize the layout engine with a reference to the parent element
---@param element Element -- The element this layout engine belongs to
function LayoutEngine:initialize(element)
self.element = element
end
--- Apply positioning offsets (top, right, bottom, left) to an element
---@param child Element -- The child element to apply offsets to
function LayoutEngine:applyPositioningOffsets(child)
if not child or not self.element then
return
end
local parent = self.element
-- 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 == Positioning.FLEX
or child.positioning == Positioning.GRID
or (child.positioning == 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 childBorderBoxHeight = child:getBorderBoxHeight()
child.y = parent.y + parent.padding.top + parent.height - child.bottom - childBorderBoxHeight
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 childBorderBoxWidth = child:getBorderBoxWidth()
child.x = parent.x + parent.padding.left + parent.width - child.right - childBorderBoxWidth
end
end
end
--- Calculate auto-width based on children and text content
---@return number -- Calculated content width
function LayoutEngine:calculateAutoWidth()
if not self.element 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
-- For HORIZONTAL flex: sum children widths + gaps
-- For VERTICAL flex: max of children widths
local isHorizontal = self.flexDirection == FlexDirection.HORIZONTAL
local totalWidth = contentWidth
local maxWidth = contentWidth
local participatingChildren = 0
for _, child in ipairs(self.element.children) do
-- Skip explicitly absolute positioned children as they don't affect parent auto-sizing
if not child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
if isHorizontal then
totalWidth = totalWidth + childBorderBoxWidth
else
maxWidth = math.max(maxWidth, childBorderBoxWidth)
end
participatingChildren = participatingChildren + 1
end
end
if isHorizontal then
-- Add gaps between children (n-1 gaps for n children)
local gapCount = math.max(0, participatingChildren - 1)
return totalWidth + (self.element.gap * gapCount)
else
return maxWidth
end
end
--- Calculate auto-height based on children and text content
---@return number -- Calculated content height
function LayoutEngine:calculateAutoHeight()
if not self.element then
return 0
end
local height = self.element:calculateTextHeight()
if not self.element.children or #self.element.children == 0 then
return height
end
-- For VERTICAL flex: sum children heights + gaps
-- For HORIZONTAL flex: max of children heights
local isVertical = self.flexDirection == FlexDirection.VERTICAL
local totalHeight = height
local maxHeight = height
local participatingChildren = 0
for _, child in ipairs(self.element.children) do
-- Skip explicitly absolute positioned children as they don't affect parent auto-sizing
if not child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations
local childBorderBoxHeight = child:getBorderBoxHeight()
if isVertical then
totalHeight = totalHeight + childBorderBoxHeight
else
maxHeight = math.max(maxHeight, childBorderBoxHeight)
end
participatingChildren = participatingChildren + 1
end
end
if isVertical then
-- Add gaps between children (n-1 gaps for n children)
local gapCount = math.max(0, participatingChildren - 1)
return totalHeight + (self.element.gap * gapCount)
else
return maxHeight
end
end
--- Main layout calculation - positions all children according to layout mode
function LayoutEngine:layoutChildren()
if not self.element then
return
end
-- Handle different positioning modes
if self.positioning == Positioning.ABSOLUTE or self.positioning == 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
return
end
-- Handle grid layout
if self.positioning == Positioning.GRID then
self:calculateGridLayout()
return
end
-- Handle flex layout
self:calculateFlexLayout()
end
--- Calculate grid layout for children
function LayoutEngine:calculateGridLayout()
if not self.element then
return
end
-- Delegate to Grid module
Grid.layoutGridItems(self.element)
end
--- Calculate flexbox layout for children
function LayoutEngine:calculateFlexLayout()
if not self.element then
return
end
local childCount = #self.element.children
if childCount == 0 then
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 == Positioning.ABSOLUTE and child._explicitlyAbsolute)
if isFlexChild then
table.insert(flexChildren, child)
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 == 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 == 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: self.element.width and self.element.height are already content dimensions (padding subtracted)
local availableMainSize = 0
local availableCrossSize = 0
if self.flexDirection == 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 == 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 == 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.element.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 == 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 == 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.element.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 == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == 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.element.gap
local freeLineSpace = availableCrossSize - totalLinesHeight
-- Apply AlignContent logic for both single and multiple lines
if self.alignContent == AlignContent.FLEX_START then
lineStartPos = 0
elseif self.alignContent == AlignContent.CENTER then
lineStartPos = freeLineSpace / 2
elseif self.alignContent == AlignContent.FLEX_END then
lineStartPos = freeLineSpace
elseif self.alignContent == AlignContent.SPACE_BETWEEN then
lineStartPos = 0
if #lines > 1 then
lineSpacing = self.element.gap + (freeLineSpace / (#lines - 1))
end
elseif self.alignContent == AlignContent.SPACE_AROUND then
local spaceAroundEach = freeLineSpace / #lines
lineStartPos = spaceAroundEach / 2
lineSpacing = self.element.gap + spaceAroundEach
elseif self.alignContent == AlignContent.STRETCH then
lineStartPos = 0
if #lines > 1 and freeLineSpace > 0 then
lineSpacing = self.element.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 == 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.element.gap
local totalContentSize = totalChildrenSize + totalGapSize
local freeSpace = availableMainSize - totalContentSize
-- Calculate initial position and spacing based on justifyContent
local startPos = 0
local itemSpacing = self.element.gap
if self.justifyContent == JustifyContent.FLEX_START then
startPos = 0
elseif self.justifyContent == JustifyContent.CENTER then
startPos = freeSpace / 2
elseif self.justifyContent == JustifyContent.FLEX_END then
startPos = freeSpace
elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then
startPos = 0
if #line > 1 then
itemSpacing = self.element.gap + (freeSpace / (#line - 1))
end
elseif self.justifyContent == JustifyContent.SPACE_AROUND then
local spaceAroundEach = freeSpace / #line
startPos = spaceAroundEach / 2
itemSpacing = self.element.gap + spaceAroundEach
elseif self.justifyContent == JustifyContent.SPACE_EVENLY then
local spaceBetween = freeSpace / (#line + 1)
startPos = spaceBetween
itemSpacing = self.element.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 == AlignSelf.AUTO then
effectiveAlign = self.alignItems
end
if self.flexDirection == 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 == AlignItems.FLEX_START then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top
elseif effectiveAlign == AlignItems.CENTER then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top
elseif effectiveAlign == AlignItems.FLEX_END then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top
elseif effectiveAlign == 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 == AlignItems.FLEX_START then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left
elseif effectiveAlign == AlignItems.CENTER then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left
elseif effectiveAlign == AlignItems.FLEX_END then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left
elseif effectiveAlign == 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 == 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
self.element:_detectOverflow()
end
--- Get content bounds (position and dimensions of content area)
---@return table -- {x, y, width, height}
function LayoutEngine:getContentBounds()
if not self.element then
return { x = 0, y = 0, width = 0, height = 0 }
end
return {
x = self.element.x + self.element.padding.left,
y = self.element.y + self.element.padding.top,
width = self.element.width,
height = self.element.height,
}
end
return LayoutEngine