diff --git a/FlexLove.lua b/FlexLove.lua index 6bce907..a5e1916 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -456,11 +456,15 @@ end ---@class Element ---@field id string ---@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children ----@field x number -- X coordinate of the element ----@field y number -- Y coordinate of the element +---@field x number|string -- X coordinate of the element +---@field y number|string -- Y coordinate of the element ---@field z number -- Z-index for layering (default: 0) ----@field width number -- Width of the element ----@field height number -- Height of the element +---@field width number|string -- Width of the element +---@field height number|string -- Height of the element +---@field top number? -- Offset from top edge (CSS-style positioning) +---@field right number? -- Offset from right edge (CSS-style positioning) +---@field bottom number? -- Offset from bottom edge (CSS-style positioning) +---@field left number? -- Offset from left edge (CSS-style positioning) ---@field children table -- Children of this element ---@field parent Element? -- Parent element (nil if top-level) ---@field border Border -- Border configuration for the element @@ -471,7 +475,7 @@ end ---@field text string? -- Text content to display in the element ---@field textColor Color -- Color of the text content ---@field textAlign TextAlign -- Alignment of the text content ----@field gap number -- Space between children elements (default: 10) +---@field gap number|string -- Space between children elements (default: 10) ---@field padding {top?:number, right?:number, bottom?:number, left?:number}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0}) ---@field margin {top?:number, right?:number, bottom?:number, left?:number} -- Margin around children (default: {top=0, right=0, bottom=0, left=0}) ---@field positioning Positioning -- Layout positioning mode (default: ABSOLUTE) @@ -498,6 +502,10 @@ Element.__index = Element ---@field z number? -- Z-index for layering (default: 0) ---@field w number|string? -- Width of the element (default: calculated automatically) ---@field h number|string? -- Height of the element (default: calculated automatically) +---@field top number|string? -- Offset from top edge (CSS-style positioning) +---@field right number|string? -- Offset from right edge (CSS-style positioning) +---@field bottom number|string? -- Offset from bottom edge (CSS-style positioning) +---@field left number|string? -- Offset from left edge (CSS-style positioning) ---@field border Border? -- Border configuration for the element ---@field borderColor Color? -- Color of the border (default: black) ---@field opacity number? @@ -530,7 +538,7 @@ function Element.new(props) self.children = {} self.callback = props.callback self.id = props.id or "" - + -- Set parent first so it's available for size calculations self.parent = props.parent @@ -587,15 +595,17 @@ function Element.new(props) }, } - if props.w then - if type(props.w) == "string" then - local value, unit = Units.parse(props.w) + -- Handle width (both w and width properties, prefer w if both exist) + local widthProp = props.w or props.width + if widthProp then + if type(widthProp) == "string" then + local value, unit = Units.parse(widthProp) self.units.width = { value = value, unit = unit } local parentWidth = self.parent and self.parent.width or viewportWidth self.width = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) else - self.width = props.w - self.units.width = { value = props.w, unit = "px" } + self.width = widthProp + self.units.width = { value = widthProp, unit = "px" } end else self.autosizing.width = true @@ -603,15 +613,17 @@ function Element.new(props) self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized end - if props.h then - if type(props.h) == "string" then - local value, unit = Units.parse(props.h) + -- Handle height (both h and height properties, prefer h if both exist) + local heightProp = props.h or props.height + if heightProp then + if type(heightProp) == "string" then + local value, unit = Units.parse(heightProp) self.units.height = { value = value, unit = unit } local parentHeight = self.parent and self.parent.height or viewportHeight self.height = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) else - self.height = props.h - self.units.height = { value = props.h, unit = "px" } + self.height = heightProp + self.units.height = { value = heightProp, unit = "px" } end else self.autosizing.height = true @@ -627,9 +639,7 @@ function Element.new(props) -- Gap percentages should be relative to the element's own size, not parent -- For horizontal flex, gap is based on width; for vertical flex, based on height local flexDir = props.flexDirection or FlexDirection.HORIZONTAL - local containerSize = (flexDir == FlexDirection.HORIZONTAL) - and self.width - or self.height + local containerSize = (flexDir == FlexDirection.HORIZONTAL) and self.width or self.height self.gap = Units.resolve(value, unit, viewportWidth, viewportHeight, containerSize) else self.gap = props.gap @@ -838,6 +848,67 @@ function Element.new(props) props.parent:addChild(self) end + -- Handle positioning properties for ALL elements (with or without parent) + -- Handle top positioning with units + if props.top then + if type(props.top) == "string" then + local value, unit = Units.parse(props.top) + self.units.top = { value = value, unit = unit } + self.top = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + else + self.top = props.top + self.units.top = { value = props.top, unit = "px" } + end + else + self.top = nil + self.units.top = nil + end + + -- Handle right positioning with units + if props.right then + if type(props.right) == "string" then + local value, unit = Units.parse(props.right) + self.units.right = { value = value, unit = unit } + self.right = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + else + self.right = props.right + self.units.right = { value = props.right, unit = "px" } + end + else + self.right = nil + self.units.right = nil + end + + -- Handle bottom positioning with units + if props.bottom then + if type(props.bottom) == "string" then + local value, unit = Units.parse(props.bottom) + self.units.bottom = { value = value, unit = unit } + self.bottom = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + else + self.bottom = props.bottom + self.units.bottom = { value = props.bottom, unit = "px" } + end + else + self.bottom = nil + self.units.bottom = nil + end + + -- Handle left positioning with units + if props.left then + if type(props.left) == "string" then + local value, unit = Units.parse(props.left) + self.units.left = { value = value, unit = unit } + self.left = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + else + self.left = props.left + self.units.left = { value = props.left, unit = "px" } + end + else + self.left = nil + self.units.left = nil + end + if self.positioning == Positioning.FLEX then self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL self.flexWrap = props.flexWrap or FlexWrap.NOWRAP @@ -894,9 +965,49 @@ function Element:addChild(child) self:layoutChildren() end +--- Apply positioning offsets (top, right, bottom, left) to an element +-- @param element The element to apply offsets to +function Element:applyPositioningOffsets(element) + if not element then + return + end + + -- For CSS-style positioning, we need the parent's bounds + local parent = element.parent + if not parent then + return + end + + -- Apply top offset (distance from parent's top edge) + if element.top then + element.y = parent.y + parent.padding.top + element.top + end + + -- Apply bottom offset (distance from parent's bottom edge) + if element.bottom then + element.y = parent.y + parent.height - parent.padding.bottom - element.height - element.bottom + end + + -- Apply left offset (distance from parent's left edge) + if element.left then + element.x = parent.x + parent.padding.left + element.left + end + + -- Apply right offset (distance from parent's right edge) + if element.right then + element.x = parent.x + parent.width - parent.padding.right - element.width - element.right + end +end + function Element:layoutChildren() if self.positioning == Positioning.ABSOLUTE then - -- Absolute positioned containers don't layout their children according to flex rules + -- Absolute 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.children) do + if child.top or child.right or child.bottom or child.left then + self:applyPositioningOffsets(child) + end + end return end @@ -1003,16 +1114,15 @@ function Element:layoutChildren() local lineGaps = math.max(0, #lines - 1) * self.gap totalLinesHeight = totalLinesHeight + lineGaps - -- For single line layouts, adjust line height based on align-items + -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size if #lines == 1 then - if - self.alignItems == AlignItems.CENTER - or self.alignItems == AlignItems.STRETCH - or self.alignItems == AlignItems.FLEX_END - 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 @@ -1114,10 +1224,14 @@ function Element:layoutChildren() elseif effectiveAlign == AlignItems.FLEX_END then child.y = self.y + self.padding.top + currentCrossPos + lineHeight - (child.height or 0) elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH always stretches children in cross-axis direction child.height = lineHeight child.y = self.y + self.padding.top + currentCrossPos end + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + -- Final position DEBUG for elements with debugId if child.debugId then print(string.format("DEBUG [%s]: Final Y position: %.2f", child.debugId, child.y)) @@ -1141,10 +1255,14 @@ function Element:layoutChildren() elseif effectiveAlign == AlignItems.FLEX_END then child.x = self.x + self.padding.left + currentCrossPos + lineHeight - (child.width or 0) elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH always stretches children in cross-axis direction child.width = lineHeight child.x = self.x + self.padding.left + currentCrossPos 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() @@ -1370,33 +1488,38 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight) -- Recalculate width if using viewport or percentage units (skip auto-sized) if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then local parentWidth = self.parent and self.parent.width or newViewportWidth - self.width = Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) + self.width = + Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) end -- Recalculate height if using viewport or percentage units (skip auto-sized) if self.units.height.unit ~= "px" and self.units.height.unit ~= "auto" then local parentHeight = self.parent and self.parent.height or newViewportHeight - self.height = Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) + self.height = + Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) end -- Recalculate position if using viewport or percentage units if self.units.x.unit ~= "px" then local parentWidth = self.parent and self.parent.width or newViewportWidth local baseX = self.parent and self.parent.x or 0 - local offsetX = Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) + local offsetX = + Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) self.x = baseX + offsetX end if self.units.y.unit ~= "px" then local parentHeight = self.parent and self.parent.height or newViewportHeight local baseY = self.parent and self.parent.y or 0 - local offsetY = Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) + local offsetY = + Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) self.y = baseY + offsetY end -- Recalculate textSize if using viewport units if self.units.textSize.unit ~= "px" then - self.textSize = Units.resolve(self.units.textSize.value, self.units.textSize.unit, newViewportWidth, newViewportHeight, nil) + self.textSize = + Units.resolve(self.units.textSize.value, self.units.textSize.unit, newViewportWidth, newViewportHeight, nil) end -- Recalculate gap if using viewport or percentage units @@ -1404,7 +1527,8 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight) local containerSize = (self.flexDirection == FlexDirection.HORIZONTAL) and (self.parent and self.parent.width or newViewportWidth) or (self.parent and self.parent.height or newViewportHeight) - self.gap = Units.resolve(self.units.gap.value, self.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) + self.gap = + Units.resolve(self.units.gap.value, self.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) end -- Recalculate spacing (padding/margin) if using viewport or percentage units