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 justifySelf JustifySelf -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf -- Alignment of the item itself along cross axis (default: AUTO)
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis (e.g., 1, "0 1 auto", "none")
---@field flexGrow number -- How much the item will grow relative to siblings (default: 0)
---@field flexShrink number -- How much the item will shrink relative to siblings (default: 1)
---@field flexBasis string|number -- Initial main size before growing/shrinking (default: "auto")
---@field textSize number? -- Resolved font size for text content in pixels
---@field minTextSize number?
---@field maxTextSize number?
@@ -186,6 +190,75 @@ function Element.init(deps)
Element._Performance = deps.Performance
end
--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis
--- Supports: number, "auto", "none", "grow shrink basis"
---@param flexValue number|string The flex shorthand value
---@return number flexGrow
---@return number flexShrink
---@return string|number flexBasis
local function parseFlexShorthand(flexValue)
-- Single number: flex-grow
if type(flexValue) == "number" then
return flexValue, 1, 0
end
-- String values
if type(flexValue) == "string" then
-- "auto" = 1 1 auto
if flexValue == "auto" then
return 1, 1, "auto"
end
-- "none" = 0 0 auto
if flexValue == "none" then
return 0, 0, "auto"
end
-- Parse "grow shrink basis" format
local parts = {}
for part in flexValue:gmatch("%S+") do
table.insert(parts, part)
end
local grow = 0
local shrink = 1
local basis = "auto"
if #parts == 1 then
-- Single value: could be grow (number) or basis (with unit)
local num = tonumber(parts[1])
if num then
grow = num
basis = 0
else
basis = parts[1]
end
elseif #parts == 2 then
-- Two values: grow shrink (both numbers) or grow basis
local num1 = tonumber(parts[1])
local num2 = tonumber(parts[2])
if num1 and num2 then
grow = num1
shrink = num2
basis = 0
elseif num1 then
grow = num1
basis = parts[2]
end
elseif #parts >= 3 then
-- Three values: grow shrink basis
grow = tonumber(parts[1]) or 0
shrink = tonumber(parts[2]) or 1
basis = parts[3]
end
return grow, shrink, basis
end
-- Default fallback
return 0, 1, "auto"
end
---@param props ElementProps
---@return Element
function Element.new(props)
@@ -571,7 +644,10 @@ function Element.new(props)
end
else
-- Store as table only if non-zero values exist
local hasNonZero = props.cornerRadius.topLeft or props.cornerRadius.topRight or props.cornerRadius.bottomLeft or props.cornerRadius.bottomRight
local hasNonZero = props.cornerRadius.topLeft
or props.cornerRadius.topRight
or props.cornerRadius.bottomLeft
or props.cornerRadius.bottomRight
if hasNonZero then
self.cornerRadius = {
topLeft = props.cornerRadius.topLeft or 0,
@@ -612,7 +688,8 @@ function Element.new(props)
-- Validate objectFit
if props.objectFit then
local validObjectFit = { fill = "fill", contain = "contain", cover = "cover", ["scale-down"] = "scale-down", none = "none" }
local validObjectFit =
{ fill = "fill", contain = "contain", cover = "cover", ["scale-down"] = "scale-down", none = "none" }
Element._utils.validateEnum(props.objectFit, validObjectFit, "objectFit")
end
self.objectFit = props.objectFit or "fill"
@@ -784,6 +861,7 @@ function Element.new(props)
y = { value = nil, unit = "px" },
textSize = { value = nil, unit = "px" },
gap = { value = nil, unit = "px" },
flexBasis = { value = nil, unit = "auto" },
padding = {
top = { value = nil, unit = "px" },
right = { value = nil, unit = "px" },
@@ -1017,6 +1095,82 @@ function Element.new(props)
self.units.gap = { value = 0, unit = "px" }
end
-- Handle flex shorthand property (sets flexGrow, flexShrink, flexBasis)
if props.flex ~= nil then
local grow, shrink, basis = parseFlexShorthand(props.flex)
-- Only set individual properties if they weren't explicitly provided
if props.flexGrow == nil then
props.flexGrow = grow
end
if props.flexShrink == nil then
props.flexShrink = shrink
end
if props.flexBasis == nil then
props.flexBasis = basis
end
end
-- Handle flexGrow property
if props.flexGrow ~= nil then
if type(props.flexGrow) == "number" and props.flexGrow >= 0 then
self.flexGrow = props.flexGrow
else
Element._ErrorHandler:warn("Element", "FLEX_001", {
element = self.id or "unnamed",
issue = "flexGrow must be a non-negative number",
value = tostring(props.flexGrow),
})
self.flexGrow = 0
end
else
self.flexGrow = 0
end
-- Handle flexShrink property
if props.flexShrink ~= nil then
if type(props.flexShrink) == "number" and props.flexShrink >= 0 then
self.flexShrink = props.flexShrink
else
Element._ErrorHandler:warn("Element", "FLEX_002", {
element = self.id or "unnamed",
issue = "flexShrink must be a non-negative number",
value = tostring(props.flexShrink),
})
self.flexShrink = 1
end
else
self.flexShrink = 1
end
-- Handle flexBasis property
if props.flexBasis ~= nil then
local isCalc = Element._Calc and Element._Calc.isCalc(props.flexBasis)
if props.flexBasis == "auto" then
self.flexBasis = "auto"
self.units.flexBasis = { value = nil, unit = "auto" }
elseif type(props.flexBasis) == "string" or isCalc then
local value, unit = Element._Units.parse(props.flexBasis)
self.units.flexBasis = { value = value, unit = unit }
-- Don't resolve yet - LayoutEngine will handle this during layout
self.flexBasis = props.flexBasis
elseif type(props.flexBasis) == "number" then
self.flexBasis = props.flexBasis
self.units.flexBasis = { value = props.flexBasis, unit = "px" }
else
Element._ErrorHandler:warn("Element", "FLEX_003", {
element = self.id or "unnamed",
issue = "flexBasis must be a number, string, or 'auto'",
value = tostring(props.flexBasis),
})
self.flexBasis = "auto"
self.units.flexBasis = { value = nil, unit = "auto" }
end
else
self.flexBasis = "auto"
self.units.flexBasis = { value = nil, unit = "auto" }
end
-- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions
-- For explicit sizing, width/height already include padding (border-box)
@@ -1433,7 +1587,10 @@ function Element.new(props)
else
-- Default: children in flex/grid containers participate in parent's layout
-- children in relative/absolute containers default to relative
if self.parent.positioning == Element._utils.enums.Positioning.FLEX or self.parent.positioning == Element._utils.enums.Positioning.GRID then
if
self.parent.positioning == Element._utils.enums.Positioning.FLEX
or self.parent.positioning == Element._utils.enums.Positioning.GRID
then
self.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
self._explicitlyAbsolute = false -- Participate in parent's layout
else
@@ -2239,7 +2396,8 @@ function Element:getAvailableContentWidth()
-- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = (
math.abs(self.padding.left - scaledContentPadding.left) < 0.1 and math.abs(self.padding.right - scaledContentPadding.right) < 0.1
math.abs(self.padding.left - scaledContentPadding.left) < 0.1
and math.abs(self.padding.right - scaledContentPadding.right) < 0.1
)
if not usingContentPaddingAsPadding then
@@ -2263,7 +2421,8 @@ function Element:getAvailableContentHeight()
-- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = (
math.abs(self.padding.top - scaledContentPadding.top) < 0.1 and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1
math.abs(self.padding.top - scaledContentPadding.top) < 0.1
and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1
)
if not usingContentPaddingAsPadding then
@@ -2286,7 +2445,10 @@ function Element:addChild(child)
-- If child was created without explicit positioning, inherit from parent
if child._originalPositioning == nil then
-- No explicit positioning was set during construction
if self.positioning == Element._utils.enums.Positioning.FLEX or self.positioning == Element._utils.enums.Positioning.GRID then
if
self.positioning == Element._utils.enums.Positioning.FLEX
or self.positioning == Element._utils.enums.Positioning.GRID
then
child.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
child._explicitlyAbsolute = false -- Participate in parent's layout
else
@@ -2496,7 +2658,8 @@ function Element:draw(backdropCanvas)
if self.animation then
local anim = self.animation:interpolate()
if anim.opacity then
drawBackgroundColor = Element._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
drawBackgroundColor =
Element._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
end
end
@@ -2565,7 +2728,8 @@ function Element:draw(backdropCanvas)
-- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden)
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil)
local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible")
and (overflowX ~= nil or overflowY ~= nil)
-- Apply scroll offset if overflow is not visible
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0)
@@ -2575,7 +2739,8 @@ function Element:draw(backdropCanvas)
-- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
local stencilFunc = Element._RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
local stencilFunc =
Element._RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
-- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround)
local currentCanvas = love.graphics.getCanvas()
@@ -2636,7 +2801,15 @@ function Element:draw(backdropCanvas)
if self.contentBlur and self.contentBlur.radius > 0 and #sortedChildren > 0 then
local blurInstance = self:getBlurInstance()
if blurInstance then
Element._Blur.applyToRegion(blurInstance, self.contentBlur.radius, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren)
Element._Blur.applyToRegion(
blurInstance,
self.contentBlur.radius,
self.x,
self.y,
borderBoxWidth,
borderBoxHeight,
drawChildren
)
else
drawChildren()
end
@@ -2826,7 +2999,12 @@ function Element:update(dt)
-- Check if we should handle scrollbar press for elements with overflow
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
local hasScrollableOverflow = (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
)
if hasScrollableOverflow and not self._scrollbarDragging then
-- Check for scrollbar press on left mouse button
@@ -2918,7 +3096,8 @@ function Element:update(dt)
local anyPressed = self._eventHandler:isAnyButtonPressed()
-- Update theme state via ThemeManager
local newThemeState = self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled)
local newThemeState =
self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled)
if self._stateId and self._elementMode == "immediate" then
local hover = newThemeState == "hover"
@@ -2995,8 +3174,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.width
-- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
local minSize = self.minTextSize
and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize
and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end
@@ -3011,8 +3192,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.height
-- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
local minSize = self.minTextSize
and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize
and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end