Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
b671f501ec Archive previous documentation and generate v0.8.0 docs [skip ci] 2026-01-06 05:48:53 +00:00
Michael Freno
3a14a2939d v0.8.0 release 2026-01-06 00:48:31 -05:00
Michael Freno
1024fd81de feat: scrollbar balance 2026-01-06 00:18:18 -05:00
Michael Freno
ce690aa5dc fix scrollbar spacing issue 2026-01-06 00:12:21 -05:00
Michael Freno
49f37a1bb0 better knob sizing 2026-01-05 15:41:33 -05:00
Michael Freno
ac3517067b move parseFlexShorthand 2026-01-05 15:15:56 -05:00
Michael Freno
6cd1c80df9 tests: added for flex/grow/shrink note failures 2026-01-05 12:22:06 -05:00
Michael Freno
157b932e80 feat: add flex grow/shrink 2026-01-05 11:28:04 -05:00
Michael Freno
121d787a0c feat: invert scroll 2026-01-05 11:07:38 -05:00
Michael Freno
8c43b45344 fixing layout issues 2026-01-05 11:01:40 -05:00
Michael Freno
32cc418449 feat: add customDraw callback support to Element
- Add customDraw property to Element.new() for custom rendering callbacks
- Add getComputedBox() method to access element's content area position/size
- Call customDraw in Element:draw() between text and children (Layer 4.5)
- Graphics state isolated with push/pop and color reset
- Enables rendering custom content (e.g. game objects) within UI elements
2026-01-05 10:37:15 -05:00
14 changed files with 11168 additions and 5456 deletions

View File

@@ -63,7 +63,7 @@ local enums = utils.enums
---@class FlexLove
local flexlove = Context
flexlove._VERSION = "0.7.3"
flexlove._VERSION = "0.8.0"
flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
flexlove._URL = "https://github.com/mikefreno/FlexLove"
flexlove._LICENSE = [[

File diff suppressed because it is too large Load Diff

View File

@@ -285,7 +285,7 @@ cp FlexLove/FlexLove.lua your-project/</code></pre>
<div class="footer">
<p>
FlexLöve v0.7.3 | MIT License |
FlexLöve v0.8.0 | MIT License |
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
>GitHub Repository</a
>

File diff suppressed because it is too large Load Diff

85
flexlove-0.8.0-1.rockspec Normal file
View File

@@ -0,0 +1,85 @@
package = "flexlove"
version = "0.8.0-1"
source = {
url = "git+https://github.com/mikefreno/FlexLove.git",
tag = "v0.8.0",
}
description = {
summary = "A comprehensive UI library providing flexbox/grid layouts, theming, animations, and event handling for LÖVE2D games",
detailed = [[
FlexLöve is a lightweight, flexible GUI library for LÖVE2D that implements a
flexbox-based layout system. The goals of this project are two-fold: first,
anyone with basic CSS knowledge should be able to use this library with minimal
learning curve. Second, this library should take you from early prototyping to
production.
Features:
- Flexbox and Grid Layout systems
- Modern theming with 9-patch support
- Animations and transitions
- Image rendering with CSS-like object-fit
- Touch events and gesture recognition
- Text input with rich editing features
- Responsive design with viewport units
- Both immediate and retained rendering modes
Going this route, you will need to link the luarocks path to your project:
(for mac/linux)
```lua
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?.lua"
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?/init.lua"
package.cpath = package.cpath .. ";/Users/<username>/.luarocks/lib/lua/<version>/?.so"
```
]],
homepage = "https://mikefreno.github.io/FlexLove/",
license = "MIT",
maintainer = "Mike Freno",
}
dependencies = {
"lua >= 5.1",
"luautf8 >= 0.1.3",
}
build = {
type = "builtin",
modules = {
["FlexLove"] = "FlexLove.lua",
["FlexLove.modules.Animation"] = "modules/Animation.lua",
["FlexLove.modules.Blur"] = "modules/Blur.lua",
["FlexLove.modules.Calc"] = "modules/Calc.lua",
["FlexLove.modules.Color"] = "modules/Color.lua",
["FlexLove.modules.Context"] = "modules/Context.lua",
["FlexLove.modules.Element"] = "modules/Element.lua",
["FlexLove.modules.ErrorHandler"] = "modules/ErrorHandler.lua",
["FlexLove.modules.EventHandler"] = "modules/EventHandler.lua",
["FlexLove.modules.FFI"] = "modules/FFI.lua",
["FlexLove.modules.GestureRecognizer"] = "modules/GestureRecognizer.lua",
["FlexLove.modules.Grid"] = "modules/Grid.lua",
["FlexLove.modules.ImageCache"] = "modules/ImageCache.lua",
["FlexLove.modules.ImageRenderer"] = "modules/ImageRenderer.lua",
["FlexLove.modules.ImageScaler"] = "modules/ImageScaler.lua",
["FlexLove.modules.InputEvent"] = "modules/InputEvent.lua",
["FlexLove.modules.LayoutEngine"] = "modules/LayoutEngine.lua",
["FlexLove.modules.MemoryScanner"] = "modules/MemoryScanner.lua",
["FlexLove.modules.ModuleLoader"] = "modules/ModuleLoader.lua",
["FlexLove.modules.NinePatch"] = "modules/NinePatch.lua",
["FlexLove.modules.Performance"] = "modules/Performance.lua",
["FlexLove.modules.Renderer"] = "modules/Renderer.lua",
["FlexLove.modules.RoundedRect"] = "modules/RoundedRect.lua",
["FlexLove.modules.ScrollManager"] = "modules/ScrollManager.lua",
["FlexLove.modules.StateManager"] = "modules/StateManager.lua",
["FlexLove.modules.TextEditor"] = "modules/TextEditor.lua",
["FlexLove.modules.Theme"] = "modules/Theme.lua",
["FlexLove.modules.types"] = "modules/types.lua",
["FlexLove.modules.Units"] = "modules/Units.lua",
["FlexLove.modules.UTF8"] = "modules/UTF8.lua",
["FlexLove.modules.utils"] = "modules/utils.lua",
},
--copy_directories = {
--"docs",
--"examples",
--},
}

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?
@@ -133,8 +137,11 @@
---@field scrollbarRadius number? -- Scrollbar corner radius
---@field scrollbarPadding number? -- Scrollbar padding from edges
---@field scrollSpeed number? -- Scroll speed multiplier
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
---@field scrollbarKnobOffset number|table? -- Scrollbar knob/handle offset (number or {x, y} or {horizontal, vertical})
---@field scrollbarPlacement string? -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space")
---@field scrollbarBalance boolean? -- When true, reserve space on both sides of content for visual balance (default: false)
---@field _overflowX boolean? -- Internal: whether content overflows horizontally
---@field _overflowY boolean? -- Internal: whether content overflows vertically
---@field _contentWidth number? -- Internal: total content width
@@ -355,6 +362,8 @@ function Element.new(props)
self.onEnter = props.onEnter
self.onEnterDeferred = props.onEnterDeferred or false
self.customDraw = props.customDraw -- Custom rendering callback
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id
@@ -568,7 +577,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,
@@ -609,7 +621,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"
@@ -781,6 +794,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" },
@@ -1014,6 +1028,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 = Element._Units.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)
@@ -1313,10 +1403,18 @@ function Element.new(props)
-- Warn if CSS positioning properties are used without absolute positioning
if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
local properties = {}
if props.top then table.insert(properties, "top") end
if props.bottom then table.insert(properties, "bottom") end
if props.left then table.insert(properties, "left") end
if props.right then table.insert(properties, "right") end
if props.top then
table.insert(properties, "top")
end
if props.bottom then
table.insert(properties, "bottom")
end
if props.left then
table.insert(properties, "left")
end
if props.right then
table.insert(properties, "right")
end
Element._ErrorHandler:warn("Element", "LAY_011", {
element = self.id or "unnamed",
positioning = self._originalPositioning or "relative",
@@ -1422,7 +1520,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
@@ -1583,10 +1684,18 @@ function Element.new(props)
-- Warn if CSS positioning properties are used without absolute positioning
if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
local properties = {}
if props.top then table.insert(properties, "top") end
if props.bottom then table.insert(properties, "bottom") end
if props.left then table.insert(properties, "left") end
if props.right then table.insert(properties, "right") end
if props.top then
table.insert(properties, "top")
end
if props.bottom then
table.insert(properties, "bottom")
end
if props.left then
table.insert(properties, "left")
end
if props.right then
table.insert(properties, "right")
end
Element._ErrorHandler:warn("Element", "LAY_011", {
element = self.id or "unnamed",
positioning = self._originalPositioning or "relative",
@@ -1827,10 +1936,13 @@ function Element.new(props)
scrollbarRadius = props.scrollbarRadius,
scrollbarPadding = props.scrollbarPadding,
scrollSpeed = props.scrollSpeed,
invertScroll = props.invertScroll,
smoothScrollEnabled = props.smoothScrollEnabled,
scrollBarStyle = props.scrollBarStyle,
scrollbarKnobOffset = props.scrollbarKnobOffset,
hideScrollbars = props.hideScrollbars,
scrollbarPlacement = props.scrollbarPlacement,
scrollbarBalance = props.scrollbarBalance,
_scrollX = props._scrollX,
_scrollY = props._scrollY,
}, scrollManagerDeps)
@@ -1845,9 +1957,12 @@ function Element.new(props)
self.scrollbarRadius = self._scrollManager.scrollbarRadius
self.scrollbarPadding = self._scrollManager.scrollbarPadding
self.scrollSpeed = self._scrollManager.scrollSpeed
self.invertScroll = self._scrollManager.invertScroll
self.scrollBarStyle = self._scrollManager.scrollBarStyle
self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset
self.hideScrollbars = self._scrollManager.hideScrollbars
self.scrollbarPlacement = self._scrollManager.scrollbarPlacement
self.scrollbarBalance = self._scrollManager.scrollbarBalance
-- Initialize state properties (will be synced from ScrollManager)
self._overflowX = false
@@ -1937,6 +2052,18 @@ function Element:getBorderBoxHeight()
return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
end
--- Get computed box dimensions (content area position and size)
--- Returns the position and size of the content area (inside padding)
---@return {x: number, y: number, width: number, height: number}
function Element:getComputedBox()
return {
x = self.x + self.padding.left,
y = self.y + self.padding.top,
width = self.width,
height = self.height,
}
end
--- Mark this element and its ancestors as dirty, requiring layout recalculation
--- Call this when element properties change that affect layout
function Element:invalidateLayout()
@@ -2206,7 +2333,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
@@ -2230,7 +2358,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
@@ -2253,7 +2382,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
@@ -2463,7 +2595,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
@@ -2477,6 +2610,14 @@ function Element:draw(backdropCanvas)
-- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module
self._renderer:drawText(self)
-- LAYER 4.5: Custom draw callback (if provided)
if self.customDraw then
love.graphics.push()
love.graphics.setColor(1, 1, 1, 1) -- Reset color to white
self.customDraw(self)
love.graphics.pop()
end
-- Draw visual feedback when element is pressed (if it has an onEvent handler and highlight is not disabled)
if self.onEvent and not self.disableHighlight and self._eventHandler then
-- Check if any button is pressed
@@ -2524,7 +2665,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)
@@ -2534,7 +2676,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()
@@ -2595,7 +2738,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
@@ -2785,7 +2936,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
@@ -2877,7 +3033,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"
@@ -2954,8 +3111,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
@@ -2970,8 +3129,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

View File

@@ -222,6 +222,123 @@ function LayoutEngine:_batchCalculatePositions(children, startX, startY, spacing
return positions
end
--- Calculate flex item sizes based on flexGrow, flexShrink, flexBasis
--- Implements CSS flexbox sizing algorithm
---@param children table Array of child elements in the flex line
---@param availableMainSize number Available space in main axis
---@param gap number Gap between items
---@param isHorizontal boolean Whether main axis is horizontal
---@return table mainSizes Array of calculated main sizes for each child
function LayoutEngine:_calculateFlexSizes(children, availableMainSize, gap, isHorizontal)
local childCount = #children
local totalGaps = math.max(0, childCount - 1) * gap
local availableForContent = availableMainSize - totalGaps
-- Step 1: Calculate hypothetical main sizes (flex basis resolution)
local hypotheticalSizes = {}
local flexBases = {}
local totalFlexBasis = 0
for i, child in ipairs(children) do
local flexBasis = child.flexBasis
local hypotheticalSize
-- Resolve flex-basis
if flexBasis == "auto" then
-- Use element's main size (width for horizontal, height for vertical)
if isHorizontal then
hypotheticalSize = child:getBorderBoxWidth()
else
hypotheticalSize = child:getBorderBoxHeight()
end
elseif type(flexBasis) == "number" then
hypotheticalSize = flexBasis
elseif type(flexBasis) == "string" and child.units.flexBasis then
-- Parse and resolve flex-basis with units
local value, unit = child.units.flexBasis.value, child.units.flexBasis.unit
hypotheticalSize =
self._Units.resolve(value, unit, self._Context.viewportWidth, self._Context.viewportHeight, availableMainSize)
else
-- Fallback to element's natural size
if isHorizontal then
hypotheticalSize = child:getBorderBoxWidth()
else
hypotheticalSize = child:getBorderBoxHeight()
end
end
-- Add margins to hypothetical size
local childMargin = child.margin
if isHorizontal then
hypotheticalSize = hypotheticalSize + childMargin.left + childMargin.right
else
hypotheticalSize = hypotheticalSize + childMargin.top + childMargin.bottom
end
flexBases[i] = hypotheticalSize
hypotheticalSizes[i] = hypotheticalSize
totalFlexBasis = totalFlexBasis + hypotheticalSize
end
-- Step 2: Determine if we need to grow or shrink
local freeSpace = availableForContent - totalFlexBasis
-- Step 3a: Handle positive free space (GROW)
if freeSpace > 0 then
local totalFlexGrow = 0
for _, child in ipairs(children) do
totalFlexGrow = totalFlexGrow + (child.flexGrow or 0)
end
if totalFlexGrow > 0 then
-- Distribute free space proportionally to flex-grow values
for i, child in ipairs(children) do
local flexGrow = child.flexGrow or 0
if flexGrow > 0 then
local growAmount = (flexGrow / totalFlexGrow) * freeSpace
hypotheticalSizes[i] = hypotheticalSizes[i] + growAmount
end
end
end
-- Step 3b: Handle negative free space (SHRINK)
elseif freeSpace < 0 then
local totalFlexShrink = 0
local totalScaledShrinkFactor = 0
for i, child in ipairs(children) do
local flexShrink = child.flexShrink or 1
totalFlexShrink = totalFlexShrink + flexShrink
-- Scaled shrink factor = flex-shrink × flex-basis
totalScaledShrinkFactor = totalScaledShrinkFactor + (flexShrink * flexBases[i])
end
if totalScaledShrinkFactor > 0 then
-- Distribute shrinkage proportionally to (flex-shrink × flex-basis)
for i, child in ipairs(children) do
local flexShrink = child.flexShrink or 1
if flexShrink > 0 then
local scaledShrinkFactor = flexShrink * flexBases[i]
local shrinkAmount = (scaledShrinkFactor / totalScaledShrinkFactor) * math.abs(freeSpace)
hypotheticalSizes[i] = math.max(0, hypotheticalSizes[i] - shrinkAmount)
end
end
end
end
-- Step 4: Return final main sizes (excluding margins)
local mainSizes = {}
for i, child in ipairs(children) do
local childMargin = child.margin
if isHorizontal then
mainSizes[i] = math.max(0, hypotheticalSizes[i] - childMargin.left - childMargin.right)
else
mainSizes[i] = math.max(0, hypotheticalSizes[i] - childMargin.top - childMargin.bottom)
end
end
return mainSizes
end
--- Layout children within this element according to positioning mode
function LayoutEngine:layoutChildren()
-- Start performance timing first (before any early returns)
@@ -328,12 +445,60 @@ function LayoutEngine:layoutChildren()
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions (padding subtracted)
local availableMainSize = 0
local availableCrossSize = 0
-- Reserve space for scrollbars if needed (reserve-space mode)
local scrollbarReservedWidth = 0
local scrollbarReservedHeight = 0
if self.element._scrollManager and self.element._scrollManager.scrollbarPlacement == "reserve-space" then
scrollbarReservedWidth, scrollbarReservedHeight = self.element._scrollManager:getReservedSpace(self.element)
end
if self.flexDirection == self._FlexDirection.HORIZONTAL then
availableMainSize = self.element.width
availableCrossSize = self.element.height
availableMainSize = self.element.width - scrollbarReservedWidth
availableCrossSize = self.element.height - scrollbarReservedHeight
else
availableMainSize = self.element.height
availableCrossSize = self.element.width
availableMainSize = self.element.height - scrollbarReservedHeight
availableCrossSize = self.element.width - scrollbarReservedWidth
end
-- Adjust children with percentage-based cross-axis dimensions when scrollbar space is reserved
if (scrollbarReservedWidth > 0 or scrollbarReservedHeight > 0) then
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for _, child in ipairs(flexChildren) do
if isHorizontal then
-- Horizontal flex: main-axis is width, cross-axis is height
-- Adjust main-axis width if percentage-based
if child.units and child.units.width and child.units.width.unit == "%" then
local newBorderBoxWidth = (child.units.width.value / 100) * availableMainSize
local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right)
child.width = newWidth
child._borderBoxWidth = newBorderBoxWidth
end
-- Adjust cross-axis height if percentage-based
if child.units and child.units.height and child.units.height.unit == "%" then
local newBorderBoxHeight = (child.units.height.value / 100) * availableCrossSize
local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom)
child.height = newHeight
child._borderBoxHeight = newBorderBoxHeight
end
else
-- Vertical flex: main-axis is height, cross-axis is width
-- Adjust main-axis height if percentage-based
if child.units and child.units.height and child.units.height.unit == "%" then
local newBorderBoxHeight = (child.units.height.value / 100) * availableMainSize
local newHeight = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom)
child.height = newHeight
child._borderBoxHeight = newBorderBoxHeight
end
-- Adjust cross-axis width if percentage-based
if child.units and child.units.width and child.units.width.unit == "%" then
local newBorderBoxWidth = (child.units.width.value / 100) * availableCrossSize
local newWidth = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right)
child.width = newWidth
child._borderBoxWidth = newBorderBoxWidth
end
end
end
end
-- Handle flex wrap: create lines of children
@@ -398,13 +563,58 @@ function LayoutEngine:layoutChildren()
end
end
-- Apply flex sizing to each line BEFORE calculating line heights
-- Performance optimization: hoist enum comparison outside loop
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for lineIndex, line in ipairs(lines) do
-- Check if any child in this line needs flex sizing
local needsFlexSizing = false
for _, child in ipairs(line) do
if (child.flexGrow and child.flexGrow > 0) or (child.flexBasis and child.flexBasis ~= "auto") then
needsFlexSizing = true
break
end
end
-- Only apply flex sizing if needed
if needsFlexSizing then
-- Calculate flex sizes for this line
local mainSizes = self:_calculateFlexSizes(line, availableMainSize, self.gap, isHorizontal)
-- Apply calculated sizes to children
for i, child in ipairs(line) do
local mainSize = mainSizes[i]
if isHorizontal then
-- Update width for horizontal flex
child.width = mainSize
child._borderBoxWidth = mainSize
-- Invalidate width cache
child._borderBoxWidthCache = nil
else
-- Update height for vertical flex
child.height = mainSize
child._borderBoxHeight = mainSize
-- Invalidate height cache
child._borderBoxHeightCache = nil
end
-- Trigger layout for child's children if any
if #child.children > 0 then
child:layoutChildren()
end
end
end
end
-- Calculate line positions and heights (including child padding)
-- Performance optimization: preallocate array if possible
local lineHeights = table.create and table.create(#lines) or {}
local totalLinesHeight = 0
-- Performance optimization: hoist enum comparison outside loop
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
-- Performance optimization: hoist enum comparison outside loop (already hoisted above)
-- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for lineIndex, line in ipairs(lines) do
local maxCrossSize = 0
@@ -435,7 +645,11 @@ function LayoutEngine:layoutChildren()
-- 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
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
@@ -508,23 +722,27 @@ function LayoutEngine:layoutChildren()
if self.justifyContent == self._JustifyContent.FLEX_START then
startPos = 0
elseif self.justifyContent == self._JustifyContent.CENTER then
startPos = freeSpace / 2
startPos = math.max(0, freeSpace / 2)
elseif self.justifyContent == self._JustifyContent.FLEX_END then
startPos = freeSpace
startPos = math.max(0, freeSpace)
elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then
startPos = 0
if #line > 1 then
if #line > 1 and freeSpace > 0 then
itemSpacing = self.gap + (freeSpace / (#line - 1))
end
elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then
if freeSpace > 0 then
local spaceAroundEach = freeSpace / #line
startPos = spaceAroundEach / 2
itemSpacing = self.gap + spaceAroundEach
end
elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then
if freeSpace > 0 then
local spaceBetween = freeSpace / (#line + 1)
startPos = spaceBetween
itemSpacing = self.gap + spaceBetween
end
end
-- Position children in this line
local currentMainPos = startPos
@@ -570,7 +788,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then
child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop
elseif effectiveAlign == alignItems_CENTER then
child.y = elementY + elementPaddingTop + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + childMarginTop
child.y = elementY
+ elementPaddingTop
+ currentCrossPos
+ ((lineHeight - childTotalCrossSize) / 2)
+ childMarginTop
elseif effectiveAlign == alignItems_FLEX_END then
child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop
elseif effectiveAlign == alignItems_STRETCH then
@@ -611,7 +833,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then
child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft
elseif effectiveAlign == alignItems_CENTER then
child.x = elementX + elementPaddingLeft + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + childMarginLeft
child.x = elementX
+ elementPaddingLeft
+ currentCrossPos
+ ((lineHeight - childTotalCrossSize) / 2)
+ childMarginLeft
elseif effectiveAlign == alignItems_FLEX_END then
child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft
elseif effectiveAlign == alignItems_STRETCH then
@@ -634,7 +860,11 @@ function LayoutEngine:layoutChildren()
end
-- Advance position by child's border-box height plus margins
currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing
currentMainPos = currentMainPos
+ child:getBorderBoxHeight()
+ child.margin.top
+ child.margin.bottom
+ itemSpacing
end
end
@@ -921,8 +1151,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- 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)
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
@@ -932,8 +1167,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- 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)
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
@@ -943,13 +1183,20 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
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
@@ -960,13 +1207,20 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
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
@@ -1002,8 +1256,10 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
@@ -1033,17 +1289,43 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- 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)
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)
self.element.gap = Units.resolve(
self.element.units.gap.value,
self.element.units.gap.unit,
newViewportWidth,
newViewportHeight,
containerSize
)
end
-- Recalculate flexBasis if using viewport or percentage units
if
self.element.units.flexBasis
and self.element.units.flexBasis.unit ~= "auto"
and self.element.units.flexBasis.unit ~= "px"
then
local value, unit = self.element.units.flexBasis.value, self.element.units.flexBasis.unit
-- flexBasis uses parent dimensions for % (main axis determines which dimension)
local parentSize = self.element.parent and self.element.parent.width or newViewportWidth
local resolvedBasis = Units.resolve(value, unit, newViewportWidth, newViewportHeight, parentSize)
if type(resolvedBasis) == "number" then
self.element.flexBasis = resolvedBasis
end
end
-- 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
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
@@ -1095,8 +1377,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
@@ -1152,8 +1439,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
@@ -1165,7 +1457,8 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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
@@ -1176,7 +1469,8 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
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)
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

View File

@@ -353,11 +353,11 @@ function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
-- Check if all borders are enabled with same width
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
local uniformWidth = allBorders and
type(self.border.top) == "number" and
self.border.top == self.border.right and
self.border.top == self.border.bottom and
self.border.top == self.border.left
local uniformWidth = allBorders
and type(self.border.top) == "number"
and self.border.top == self.border.right
and self.border.top == self.border.bottom
and self.border.top == self.border.left
if uniformWidth then
-- Draw complete rounded rectangle border with uniform width
@@ -938,28 +938,30 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
knobOffsetY = themeOffset.vertical
end
-- Extract contentPadding from frame for knob sizing
local framePaddingLeft = 0
local framePaddingTop = 0
local framePaddingRight = 0
local framePaddingBottom = 0
if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then
framePaddingLeft = frameComponent._ninePatchData.contentPadding.left or 0
framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0
framePaddingRight = frameComponent._ninePatchData.contentPadding.right or 0
framePaddingBottom = frameComponent._ninePatchData.contentPadding.bottom or 0
end
-- Draw track (frame) if component exists
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
self._NinePatch.draw(
frameComponent,
frameComponent._loadedAtlas,
trackX,
trackY,
element.scrollbarWidth,
dims.vertical.trackHeight
)
self._NinePatch.draw(frameComponent, frameComponent._loadedAtlas, trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight)
end
-- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then
self._NinePatch.draw(
barComponent,
barComponent._loadedAtlas,
trackX + knobOffsetX,
trackY + dims.vertical.thumbY + knobOffsetY,
element.scrollbarWidth,
dims.vertical.thumbHeight
)
-- Adjust knob dimensions to account for frame's contentPadding
-- Vertical scrollbar: width affected by left+right, height affected by top+bottom
local knobWidth = element.scrollbarWidth
local knobHeight = dims.vertical.thumbHeight - framePaddingTop / 2
self._NinePatch.draw(barComponent, barComponent._loadedAtlas, trackX + knobOffsetX, trackY + dims.vertical.thumbY + knobOffsetY, knobWidth, knobHeight)
end
else
-- Fallback to color-based rendering
@@ -1013,27 +1015,36 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
knobOffsetY = themeOffset.y
end
-- Extract contentPadding from frame for knob sizing
local framePaddingLeft = 0
local framePaddingTop = 0
local framePaddingRight = 0
local framePaddingBottom = 0
if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then
framePaddingLeft = frameComponent._ninePatchData.contentPadding.left or 0
framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0
framePaddingRight = frameComponent._ninePatchData.contentPadding.right or 0
framePaddingBottom = frameComponent._ninePatchData.contentPadding.bottom or 0
end
-- Draw track (frame) if component exists
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
self._NinePatch.draw(
frameComponent,
frameComponent._loadedAtlas,
trackX,
trackY,
dims.horizontal.trackWidth,
element.scrollbarWidth
)
self._NinePatch.draw(frameComponent, frameComponent._loadedAtlas, trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth)
end
-- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then
-- Adjust knob dimensions to account for frame's contentPadding
-- Horizontal scrollbar: width affected by left+right, height affected by top+bottom
local knobWidth = dims.horizontal.thumbWidth - framePaddingLeft / 2
local knobHeight = element.scrollbarWidth - framePaddingTop - framePaddingBottom
self._NinePatch.draw(
barComponent,
barComponent._loadedAtlas,
trackX + dims.horizontal.thumbX + knobOffsetX,
trackY + knobOffsetY,
dims.horizontal.thumbWidth,
element.scrollbarWidth
knobWidth,
knobHeight
)
end
else

View File

@@ -8,9 +8,12 @@
---@field scrollbarRadius number -- Border radius for scrollbars
---@field scrollbarPadding number -- Padding around scrollbar
---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit)
---@field invertScroll boolean -- Invert mouse wheel scroll direction (default: false)
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
---@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
---@field scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space")
---@field scrollbarBalance boolean -- When true, reserve space on both sides of content for visual balance (default: false)
---@field touchScrollEnabled boolean -- Enable touch scrolling
---@field momentumScrollEnabled boolean -- Enable momentum scrolling
---@field bounceEnabled boolean -- Enable bounce effects at boundaries
@@ -83,6 +86,7 @@ function ScrollManager.new(config, deps)
self.scrollbarRadius = config.scrollbarRadius or 6
self.scrollbarPadding = config.scrollbarPadding or 2
self.scrollSpeed = config.scrollSpeed or 20
self.invertScroll = config.invertScroll or false
self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default)
-- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical}
@@ -96,6 +100,12 @@ function ScrollManager.new(config, deps)
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false)
-- Scrollbar placement: "reserve-space" (default) or "overlay"
self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space"
-- Scrollbar balance: when true, reserve space on both sides for visual balance
self.scrollbarBalance = config.scrollbarBalance or false
-- Touch scrolling configuration
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
@@ -144,6 +154,36 @@ function ScrollManager.new(config, deps)
return self
end
--- Get the space reserved for scrollbars (width and height reduction)
--- This is called BEFORE layout to reduce available space for children
---@param element Element The parent Element instance
---@return number reservedWidth, number reservedHeight
function ScrollManager:getReservedSpace(element)
if self.scrollbarPlacement ~= "reserve-space" then
return 0, 0
end
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
local reservedWidth = 0
local reservedHeight = 0
-- Reserve space for vertical scrollbar if overflow mode requires it
if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then
local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2)
reservedWidth = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace
end
-- Reserve space for horizontal scrollbar if overflow mode requires it
if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then
local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2)
reservedHeight = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace
end
return reservedWidth, reservedHeight
end
--- Detect if content overflows container bounds
---@param element Element The parent Element instance
function ScrollManager:detectOverflow(element)
@@ -197,6 +237,14 @@ function ScrollManager:detectOverflow(element)
local containerWidth = element.width - element.padding.left - element.padding.right
local containerHeight = element.height - element.padding.top - element.padding.bottom
-- If scrollbarPlacement is "reserve-space", we need to subtract the reserved space
-- because the layout already accounted for it, but element.width/height are still full size
if self.scrollbarPlacement == "reserve-space" then
local reservedWidth, reservedHeight = self:getReservedSpace()
containerWidth = containerWidth - reservedWidth
containerHeight = containerHeight - reservedHeight
end
self._overflowX = self._contentWidth > containerWidth
self._overflowY = self._contentHeight > containerHeight
@@ -582,6 +630,9 @@ function ScrollManager:handleWheel(x, y)
-- Vertical scrolling
if y ~= 0 and hasVerticalOverflow then
local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up
if self.invertScroll then
delta = -delta -- Invert scroll direction if enabled
end
if self.smoothScrollEnabled then
-- Set target for smooth scrolling instead of instant jump
self._targetScrollY = self._utils.clamp((self._targetScrollY or self._scrollY) + delta, 0, self._maxScrollY)
@@ -596,6 +647,9 @@ function ScrollManager:handleWheel(x, y)
-- Horizontal scrolling
if x ~= 0 and hasHorizontalOverflow then
local delta = -x * self.scrollSpeed
if self.invertScroll then
delta = -delta -- Invert scroll direction if enabled
end
if self.smoothScrollEnabled then
-- Set target for smooth scrolling instead of instant jump
self._targetScrollX = self._utils.clamp((self._targetScrollX or self._scrollX) + delta, 0, self._maxScrollX)
@@ -666,6 +720,8 @@ function ScrollManager:getState()
_scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false,
scrollBarStyle = self.scrollBarStyle,
scrollbarKnobOffset = self.scrollbarKnobOffset,
scrollbarPlacement = self.scrollbarPlacement,
scrollbarBalance = self.scrollbarBalance,
_overflowX = self._overflowX,
_overflowY = self._overflowY,
_contentWidth = self._contentWidth,
@@ -744,6 +800,14 @@ function ScrollManager:setState(state)
self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0)
end
if state.scrollbarPlacement ~= nil then
self.scrollbarPlacement = state.scrollbarPlacement
end
if state.scrollbarBalance ~= nil then
self.scrollbarBalance = state.scrollbarBalance
end
if state._overflowX ~= nil then
self._overflowX = state._overflowX
end

View File

@@ -265,4 +265,73 @@ function Units.isValid(unitStr)
return validUnits[unit] == true
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
function Units.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
return Units

View File

@@ -67,6 +67,10 @@ local AnimationProps = {}
---@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: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil)
---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0)
---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1)
---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto")
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
@@ -122,9 +126,12 @@ local AnimationProps = {}
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false)
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering)
---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset)
---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content)
---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false)
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
package.path = package.path .. ";./?.lua;./modules/?.lua"
local originalSearchers = package.searchers or package.loaders
table.insert(originalSearchers, 2, function(modname)
if modname:match("^FlexLove%.modules%.") then
local moduleName = modname:gsub("^FlexLove%.modules%.", "")
return function()
return require("modules." .. moduleName)
end
end
end)
require("testing.loveStub")
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
FlexLove.init()
TestScrollbarPlacement = {}
function TestScrollbarPlacement:setUp()
FlexLove.setMode("retained")
end
function TestScrollbarPlacement:test_reserve_space_with_percentage_height_children()
-- Test case from user: horizontal scroll container with 100% height children
-- Should NOT cause vertical overflow
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "horizontal",
overflow = "scroll", -- Always shows scrollbars
scrollbarPlacement = "reserve-space",
})
local child1 = FlexLove.new({
width = 100,
height = "100%",
parent = container,
})
-- Trigger layout
container:layoutChildren()
container._scrollManager:detectOverflow(container)
local overflowX, overflowY = container._scrollManager:hasOverflow()
-- Child height should be reduced to account for horizontal scrollbar
-- Default scrollbar is 12px + 2px padding on each side = 16px
-- So child height should be 200 - 16 = 184px
luaunit.assertEquals(child1.height, 184)
-- Should have horizontal overflow (scroll mode), but NOT vertical
luaunit.assertFalse(overflowY, "Should not have vertical overflow with 100% height child")
end
function TestScrollbarPlacement:test_reserve_space_with_percentage_width_children()
-- Vertical scroll container with 100% width children
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "column",
overflow = "scroll", -- Always shows scrollbars
scrollbarPlacement = "reserve-space",
})
local child1 = FlexLove.new({
width = "100%",
height = 100,
parent = container,
})
-- Trigger layout
container:layoutChildren()
container._scrollManager:detectOverflow(container)
local overflowX, overflowY = container._scrollManager:hasOverflow()
-- Child width should be reduced to account for vertical scrollbar
-- Default scrollbar is 12px + 2px padding on each side = 16px
-- So child width should be 200 - 16 = 184px
luaunit.assertEquals(child1.width, 184)
-- Should have vertical overflow (scroll mode), but NOT horizontal
luaunit.assertFalse(overflowX, "Should not have horizontal overflow with 100% width child")
end
function TestScrollbarPlacement:test_overlay_mode_no_size_adjustment()
-- Overlay mode should NOT adjust child sizes
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "horizontal",
overflow = "scroll",
scrollbarPlacement = "overlay",
})
local child1 = FlexLove.new({
width = 100,
height = "100%",
parent = container,
})
-- Trigger layout
container:layoutChildren()
-- Child height should be full 200px (no reduction for scrollbar)
luaunit.assertEquals(child1.height, 200)
end
function TestScrollbarPlacement:test_auto_overflow_reserves_space_only_when_needed()
-- With overflow="auto" and reserve-space mode, space is reserved preemptively
-- But scrollbars should only be visible when content actually overflows
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "column",
overflow = "auto",
scrollbarPlacement = "reserve-space",
})
-- Child that doesn't cause overflow
local child1 = FlexLove.new({
width = "100%",
height = 100,
parent = container,
})
-- Trigger layout and overflow detection
container:layoutChildren()
container._scrollManager:detectOverflow(container)
local overflowX, overflowY = container._scrollManager:hasOverflow()
-- No overflow detected since content (100px) < available height (184px after scrollbar reservation)
luaunit.assertFalse(overflowY, "Should not have overflow")
-- Space is reserved preemptively with overflow="auto" to avoid layout shifts
luaunit.assertEquals(child1.width, 184, "Space should be reserved preemptively with auto mode")
end
function TestScrollbarPlacement:test_vertical_overflow_detected_with_reserved_space()
-- Test that overflow is properly detected when using reserve-space
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "column",
overflow = "auto",
scrollbarPlacement = "reserve-space",
})
-- Child that WILL cause overflow
local child1 = FlexLove.new({
width = "100%",
height = 300,
parent = container,
})
-- Trigger layout and overflow detection
container:layoutChildren()
container._scrollManager:detectOverflow(container)
local overflowX, overflowY = container._scrollManager:hasOverflow()
-- Should detect vertical overflow
luaunit.assertTrue(overflowY, "Should detect vertical overflow")
-- Child width should be reduced for vertical scrollbar
luaunit.assertEquals(child1.width, 184, "Child width should be reduced for scrollbar")
end
function TestScrollbarPlacement:test_scrollbar_balance_vertical()
-- Test scrollbarBalance with vertical scrollbar
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "column",
overflow = "scroll",
scrollbarPlacement = "reserve-space",
scrollbarBalance = true,
})
local child1 = FlexLove.new({
width = "100%",
height = 100,
parent = container
})
container:layoutChildren()
local reservedW, reservedH = container._scrollManager:getReservedSpace(container)
-- Should reserve double the space (16 * 2 = 32)
luaunit.assertEquals(reservedW, 32, "Should reserve doubled width for balance")
-- Child width should account for balanced space
luaunit.assertEquals(child1.width, 168, "Child width should be 200 - 32")
end
function TestScrollbarPlacement:test_scrollbar_balance_horizontal()
-- Test scrollbarBalance with horizontal scrollbar
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
flexDirection = "horizontal",
overflow = "scroll",
scrollbarPlacement = "reserve-space",
scrollbarBalance = true,
})
local child1 = FlexLove.new({
width = 100,
height = "100%",
parent = container
})
container:layoutChildren()
local reservedW, reservedH = container._scrollManager:getReservedSpace(container)
-- Should reserve double the space (16 * 2 = 32)
luaunit.assertEquals(reservedH, 32, "Should reserve doubled height for balance")
-- Child height should account for balanced space
luaunit.assertEquals(child1.height, 168, "Child height should be 200 - 32")
end
function TestScrollbarPlacement:test_scrollbar_balance_both()
-- Test scrollbarBalance with both scrollbars
local container = FlexLove.new({
width = 200,
height = 200,
positioning = "flex",
overflow = "scroll",
scrollbarPlacement = "reserve-space",
scrollbarBalance = true,
})
local child1 = FlexLove.new({
width = "100%",
height = "100%",
parent = container
})
container:layoutChildren()
local reservedW, reservedH = container._scrollManager:getReservedSpace(container)
-- Both should reserve double the space
luaunit.assertEquals(reservedW, 32, "Should reserve doubled width for balance")
luaunit.assertEquals(reservedH, 32, "Should reserve doubled height for balance")
-- Child should be sized to balanced available space
luaunit.assertEquals(child1.width, 168, "Child width should be 200 - 32")
luaunit.assertEquals(child1.height, 168, "Child height should be 200 - 32")
end
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

View File

@@ -44,6 +44,7 @@ local testFiles = {
"testing/__tests__/element_test.lua",
"testing/__tests__/element_mode_override_test.lua",
"testing/__tests__/event_handler_test.lua",
"testing/__tests__/flex_grow_shrink_test.lua",
"testing/__tests__/flexlove_test.lua",
"testing/__tests__/grid_test.lua",
"testing/__tests__/image_cache_test.lua",
@@ -62,6 +63,7 @@ local testFiles = {
"testing/__tests__/retained_prop_stability_test.lua",
"testing/__tests__/roundedrect_test.lua",
"testing/__tests__/scroll_manager_test.lua",
"testing/__tests__/scrollbar_placement_test.lua",
"testing/__tests__/text_editor_test.lua",
"testing/__tests__/theme_test.lua",
"testing/__tests__/touch_events_test.lua",