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 ---@class FlexLove
local flexlove = Context 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._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
flexlove._URL = "https://github.com/mikefreno/FlexLove" flexlove._URL = "https://github.com/mikefreno/FlexLove"
flexlove._LICENSE = [[ 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"> <div class="footer">
<p> <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" <a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
>GitHub Repository</a >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 flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP)
---@field justifySelf JustifySelf -- Alignment of the item itself along main axis (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 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 textSize number? -- Resolved font size for text content in pixels
---@field minTextSize number? ---@field minTextSize number?
---@field maxTextSize number? ---@field maxTextSize number?
@@ -133,8 +137,11 @@
---@field scrollbarRadius number? -- Scrollbar corner radius ---@field scrollbarRadius number? -- Scrollbar corner radius
---@field scrollbarPadding number? -- Scrollbar padding from edges ---@field scrollbarPadding number? -- Scrollbar padding from edges
---@field scrollSpeed number? -- Scroll speed multiplier ---@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 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 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 _overflowX boolean? -- Internal: whether content overflows horizontally
---@field _overflowY boolean? -- Internal: whether content overflows vertically ---@field _overflowY boolean? -- Internal: whether content overflows vertically
---@field _contentWidth number? -- Internal: total content width ---@field _contentWidth number? -- Internal: total content width
@@ -355,6 +362,8 @@ function Element.new(props)
self.onEnter = props.onEnter self.onEnter = props.onEnter
self.onEnterDeferred = props.onEnterDeferred or false 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) -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id self._stateId = self.id
@@ -568,7 +577,10 @@ function Element.new(props)
end end
else else
-- Store as table only if non-zero values exist -- 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 if hasNonZero then
self.cornerRadius = { self.cornerRadius = {
topLeft = props.cornerRadius.topLeft or 0, topLeft = props.cornerRadius.topLeft or 0,
@@ -609,7 +621,8 @@ function Element.new(props)
-- Validate objectFit -- Validate objectFit
if props.objectFit then 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") Element._utils.validateEnum(props.objectFit, validObjectFit, "objectFit")
end end
self.objectFit = props.objectFit or "fill" self.objectFit = props.objectFit or "fill"
@@ -781,6 +794,7 @@ function Element.new(props)
y = { value = nil, unit = "px" }, y = { value = nil, unit = "px" },
textSize = { value = nil, unit = "px" }, textSize = { value = nil, unit = "px" },
gap = { value = nil, unit = "px" }, gap = { value = nil, unit = "px" },
flexBasis = { value = nil, unit = "auto" },
padding = { padding = {
top = { value = nil, unit = "px" }, top = { value = nil, unit = "px" },
right = { value = nil, unit = "px" }, right = { value = nil, unit = "px" },
@@ -1014,6 +1028,82 @@ function Element.new(props)
self.units.gap = { value = 0, unit = "px" } self.units.gap = { value = 0, unit = "px" }
end 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 -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions
-- For explicit sizing, width/height already include padding (border-box) -- 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 -- 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 if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
local properties = {} local properties = {}
if props.top then table.insert(properties, "top") end if props.top then
if props.bottom then table.insert(properties, "bottom") end table.insert(properties, "top")
if props.left then table.insert(properties, "left") end end
if props.right then table.insert(properties, "right") 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._ErrorHandler:warn("Element", "LAY_011", {
element = self.id or "unnamed", element = self.id or "unnamed",
positioning = self._originalPositioning or "relative", positioning = self._originalPositioning or "relative",
@@ -1422,7 +1520,10 @@ function Element.new(props)
else else
-- Default: children in flex/grid containers participate in parent's layout -- Default: children in flex/grid containers participate in parent's layout
-- children in relative/absolute containers default to relative -- 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.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
self._explicitlyAbsolute = false -- Participate in parent's layout self._explicitlyAbsolute = false -- Participate in parent's layout
else else
@@ -1500,7 +1601,7 @@ function Element.new(props)
-- Warn if explicit x/y is set on a child that will be positioned by flex layout -- Warn if explicit x/y is set on a child that will be positioned by flex layout
-- This position will be overridden unless the child has positioning="absolute" -- This position will be overridden unless the child has positioning="absolute"
local parentWillUseFlex = self.parent.positioning ~= "grid" local parentWillUseFlex = self.parent.positioning ~= "grid"
local childIsRelative = self.positioning ~= "absolute" or not self._explicitlyAbsolute local childIsRelative = self.positioning ~= "absolute" or not self._explicitlyAbsolute
if parentWillUseFlex and childIsRelative and (props.x or props.y) then if parentWillUseFlex and childIsRelative and (props.x or props.y) then
Element._ErrorHandler:warn("Element", "LAY_008", { Element._ErrorHandler:warn("Element", "LAY_008", {
@@ -1583,10 +1684,18 @@ function Element.new(props)
-- Warn if CSS positioning properties are used without absolute positioning -- 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 if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
local properties = {} local properties = {}
if props.top then table.insert(properties, "top") end if props.top then
if props.bottom then table.insert(properties, "bottom") end table.insert(properties, "top")
if props.left then table.insert(properties, "left") end end
if props.right then table.insert(properties, "right") 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._ErrorHandler:warn("Element", "LAY_011", {
element = self.id or "unnamed", element = self.id or "unnamed",
positioning = self._originalPositioning or "relative", positioning = self._originalPositioning or "relative",
@@ -1827,10 +1936,13 @@ function Element.new(props)
scrollbarRadius = props.scrollbarRadius, scrollbarRadius = props.scrollbarRadius,
scrollbarPadding = props.scrollbarPadding, scrollbarPadding = props.scrollbarPadding,
scrollSpeed = props.scrollSpeed, scrollSpeed = props.scrollSpeed,
invertScroll = props.invertScroll,
smoothScrollEnabled = props.smoothScrollEnabled, smoothScrollEnabled = props.smoothScrollEnabled,
scrollBarStyle = props.scrollBarStyle, scrollBarStyle = props.scrollBarStyle,
scrollbarKnobOffset = props.scrollbarKnobOffset, scrollbarKnobOffset = props.scrollbarKnobOffset,
hideScrollbars = props.hideScrollbars, hideScrollbars = props.hideScrollbars,
scrollbarPlacement = props.scrollbarPlacement,
scrollbarBalance = props.scrollbarBalance,
_scrollX = props._scrollX, _scrollX = props._scrollX,
_scrollY = props._scrollY, _scrollY = props._scrollY,
}, scrollManagerDeps) }, scrollManagerDeps)
@@ -1845,9 +1957,12 @@ function Element.new(props)
self.scrollbarRadius = self._scrollManager.scrollbarRadius self.scrollbarRadius = self._scrollManager.scrollbarRadius
self.scrollbarPadding = self._scrollManager.scrollbarPadding self.scrollbarPadding = self._scrollManager.scrollbarPadding
self.scrollSpeed = self._scrollManager.scrollSpeed self.scrollSpeed = self._scrollManager.scrollSpeed
self.invertScroll = self._scrollManager.invertScroll
self.scrollBarStyle = self._scrollManager.scrollBarStyle self.scrollBarStyle = self._scrollManager.scrollBarStyle
self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset
self.hideScrollbars = self._scrollManager.hideScrollbars self.hideScrollbars = self._scrollManager.hideScrollbars
self.scrollbarPlacement = self._scrollManager.scrollbarPlacement
self.scrollbarBalance = self._scrollManager.scrollbarBalance
-- Initialize state properties (will be synced from ScrollManager) -- Initialize state properties (will be synced from ScrollManager)
self._overflowX = false self._overflowX = false
@@ -1937,6 +2052,18 @@ function Element:getBorderBoxHeight()
return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
end 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 --- Mark this element and its ancestors as dirty, requiring layout recalculation
--- Call this when element properties change that affect layout --- Call this when element properties change that affect layout
function Element:invalidateLayout() function Element:invalidateLayout()
@@ -2206,7 +2333,8 @@ function Element:getAvailableContentWidth()
-- Check if the element is using the scaled 9-patch contentPadding as its padding -- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels) -- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = ( 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 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 -- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels) -- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = ( 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 if not usingContentPaddingAsPadding then
@@ -2253,7 +2382,10 @@ function Element:addChild(child)
-- If child was created without explicit positioning, inherit from parent -- If child was created without explicit positioning, inherit from parent
if child._originalPositioning == nil then if child._originalPositioning == nil then
-- No explicit positioning was set during construction -- 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.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
child._explicitlyAbsolute = false -- Participate in parent's layout child._explicitlyAbsolute = false -- Participate in parent's layout
else else
@@ -2463,7 +2595,8 @@ function Element:draw(backdropCanvas)
if self.animation then if self.animation then
local anim = self.animation:interpolate() local anim = self.animation:interpolate()
if anim.opacity then 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
end end
@@ -2477,6 +2610,14 @@ function Element:draw(backdropCanvas)
-- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module -- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module
self._renderer:drawText(self) 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) -- 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 if self.onEvent and not self.disableHighlight and self._eventHandler then
-- Check if any button is pressed -- Check if any button is pressed
@@ -2524,7 +2665,8 @@ function Element:draw(backdropCanvas)
-- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden) -- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden)
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY 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 -- Apply scroll offset if overflow is not visible
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) 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 -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) 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 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) -- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround)
local currentCanvas = love.graphics.getCanvas() 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 if self.contentBlur and self.contentBlur.radius > 0 and #sortedChildren > 0 then
local blurInstance = self:getBlurInstance() local blurInstance = self:getBlurInstance()
if blurInstance then 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 else
drawChildren() drawChildren()
end end
@@ -2785,7 +2936,12 @@ function Element:update(dt)
-- Check if we should handle scrollbar press for elements with overflow -- Check if we should handle scrollbar press for elements with overflow
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY 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 if hasScrollableOverflow and not self._scrollbarDragging then
-- Check for scrollbar press on left mouse button -- Check for scrollbar press on left mouse button
@@ -2877,7 +3033,8 @@ function Element:update(dt)
local anyPressed = self._eventHandler:isAnyButtonPressed() local anyPressed = self._eventHandler:isAnyButtonPressed()
-- Update theme state via ThemeManager -- 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 if self._stateId and self._elementMode == "immediate" then
local hover = newThemeState == "hover" local hover = newThemeState == "hover"
@@ -2954,8 +3111,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.width self.textSize = (value / 100) * self.width
-- Apply min/max constraints -- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) local minSize = self.minTextSize
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) 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 if minSize and self.textSize < minSize then
self.textSize = minSize self.textSize = minSize
end end
@@ -2970,8 +3129,10 @@ function Element:resize(newGameWidth, newGameHeight)
self.textSize = (value / 100) * self.height self.textSize = (value / 100) * self.height
-- Apply min/max constraints -- Apply min/max constraints
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) local minSize = self.minTextSize
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) 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 if minSize and self.textSize < minSize then
self.textSize = minSize self.textSize = minSize
end end

View File

@@ -222,6 +222,123 @@ function LayoutEngine:_batchCalculatePositions(children, startX, startY, spacing
return positions return positions
end 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 --- Layout children within this element according to positioning mode
function LayoutEngine:layoutChildren() function LayoutEngine:layoutChildren()
-- Start performance timing first (before any early returns) -- 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) -- BORDER-BOX MODEL: element.width and element.height are already content dimensions (padding subtracted)
local availableMainSize = 0 local availableMainSize = 0
local availableCrossSize = 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 if self.flexDirection == self._FlexDirection.HORIZONTAL then
availableMainSize = self.element.width availableMainSize = self.element.width - scrollbarReservedWidth
availableCrossSize = self.element.height availableCrossSize = self.element.height - scrollbarReservedHeight
else else
availableMainSize = self.element.height availableMainSize = self.element.height - scrollbarReservedHeight
availableCrossSize = self.element.width 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 end
-- Handle flex wrap: create lines of children -- Handle flex wrap: create lines of children
@@ -398,13 +563,58 @@ function LayoutEngine:layoutChildren()
end end
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) -- Calculate line positions and heights (including child padding)
-- Performance optimization: preallocate array if possible -- Performance optimization: preallocate array if possible
local lineHeights = table.create and table.create(#lines) or {} local lineHeights = table.create and table.create(#lines) or {}
local totalLinesHeight = 0 local totalLinesHeight = 0
-- Performance optimization: hoist enum comparison outside loop -- Performance optimization: hoist enum comparison outside loop (already hoisted above)
local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL -- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL
for lineIndex, line in ipairs(lines) do for lineIndex, line in ipairs(lines) do
local maxCrossSize = 0 local maxCrossSize = 0
@@ -435,7 +645,11 @@ function LayoutEngine:layoutChildren()
-- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size
if #lines == 1 then 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 -- STRETCH, CENTER, and FLEX_END should use full available cross size
lineHeights[1] = availableCrossSize lineHeights[1] = availableCrossSize
totalLinesHeight = availableCrossSize totalLinesHeight = availableCrossSize
@@ -508,22 +722,26 @@ function LayoutEngine:layoutChildren()
if self.justifyContent == self._JustifyContent.FLEX_START then if self.justifyContent == self._JustifyContent.FLEX_START then
startPos = 0 startPos = 0
elseif self.justifyContent == self._JustifyContent.CENTER then elseif self.justifyContent == self._JustifyContent.CENTER then
startPos = freeSpace / 2 startPos = math.max(0, freeSpace / 2)
elseif self.justifyContent == self._JustifyContent.FLEX_END then elseif self.justifyContent == self._JustifyContent.FLEX_END then
startPos = freeSpace startPos = math.max(0, freeSpace)
elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then
startPos = 0 startPos = 0
if #line > 1 then if #line > 1 and freeSpace > 0 then
itemSpacing = self.gap + (freeSpace / (#line - 1)) itemSpacing = self.gap + (freeSpace / (#line - 1))
end end
elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then
local spaceAroundEach = freeSpace / #line if freeSpace > 0 then
startPos = spaceAroundEach / 2 local spaceAroundEach = freeSpace / #line
itemSpacing = self.gap + spaceAroundEach startPos = spaceAroundEach / 2
itemSpacing = self.gap + spaceAroundEach
end
elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then
local spaceBetween = freeSpace / (#line + 1) if freeSpace > 0 then
startPos = spaceBetween local spaceBetween = freeSpace / (#line + 1)
itemSpacing = self.gap + spaceBetween startPos = spaceBetween
itemSpacing = self.gap + spaceBetween
end
end end
-- Position children in this line -- Position children in this line
@@ -570,7 +788,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then if effectiveAlign == alignItems_FLEX_START then
child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop
elseif effectiveAlign == alignItems_CENTER then 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 elseif effectiveAlign == alignItems_FLEX_END then
child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop
elseif effectiveAlign == alignItems_STRETCH then elseif effectiveAlign == alignItems_STRETCH then
@@ -611,7 +833,11 @@ function LayoutEngine:layoutChildren()
if effectiveAlign == alignItems_FLEX_START then if effectiveAlign == alignItems_FLEX_START then
child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft
elseif effectiveAlign == alignItems_CENTER then 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 elseif effectiveAlign == alignItems_FLEX_END then
child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft
elseif effectiveAlign == alignItems_STRETCH then elseif effectiveAlign == alignItems_STRETCH then
@@ -634,7 +860,11 @@ function LayoutEngine:layoutChildren()
end end
-- Advance position by child's border-box height plus margins -- 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
end end
@@ -921,8 +1151,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved -- 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 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 local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth
self.element._borderBoxWidth = self.element._borderBoxWidth = Units.resolve(
Units.resolve(self.element.units.width.value, self.element.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) 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 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) -- Reapply base scaling to pixel widths (border-box)
self.element._borderBoxWidth = self.element.units.width.value * scaleX 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 -- 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 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 local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight
self.element._borderBoxHeight = self.element._borderBoxHeight = Units.resolve(
Units.resolve(self.element.units.height.value, self.element.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) 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 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) -- Reapply base scaling to pixel heights (border-box)
self.element._borderBoxHeight = self.element.units.height.value * scaleY 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 if self.element.units.x.unit ~= "px" then
local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth 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 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 self.element.x = baseX + offsetX
else else
-- For pixel units, update position relative to parent's new position (with base scaling) -- For pixel units, update position relative to parent's new position (with base scaling)
if self.element.parent then if self.element.parent then
local baseX = self.element.parent.x 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 self.element.x = baseX + scaledOffset
elseif self._Context.baseScale then elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling -- 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 if self.element.units.y.unit ~= "px" then
local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight 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 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 self.element.y = baseY + offsetY
else else
-- For pixel units, update position relative to parent's new position (with base scaling) -- For pixel units, update position relative to parent's new position (with base scaling)
if self.element.parent then if self.element.parent then
local baseY = self.element.parent.y 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 self.element.y = baseY + scaledOffset
elseif self._Context.baseScale then elseif self._Context.baseScale then
-- Top-level element with pixel position - apply base scaling -- Top-level element with pixel position - apply base scaling
@@ -1002,8 +1256,10 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end end
-- Apply min/max constraints (with base scaling) -- 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 minSize = self.element.minTextSize
local maxSize = self.element.maxTextSize and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) 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 if minSize and self.element.textSize < minSize then
self.element.textSize = minSize self.element.textSize = minSize
@@ -1033,17 +1289,43 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
-- Recalculate gap if using viewport or percentage units -- Recalculate gap if using viewport or percentage units
if self.element.units.gap.unit ~= "px" then 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) 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 end
-- Recalculate spacing (padding/margin) if using viewport or percentage units -- Recalculate spacing (padding/margin) if using viewport or percentage units
-- For percentage-based padding: -- For percentage-based padding:
-- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) -- - 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) -- - 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 parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth
local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight or self.element._borderBoxHeight or newViewportHeight 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) -- Handle shorthand properties first (horizontal/vertical)
local resolvedHorizontalPadding = nil local resolvedHorizontalPadding = nil
@@ -1095,8 +1377,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
elseif self.element.units.padding[side].unit ~= "px" then elseif self.element.units.padding[side].unit ~= "px" then
-- Recalculate non-pixel units -- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.element.padding[side] = self.element.padding[side] = Units.resolve(
Units.resolve(self.element.units.padding[side].value, self.element.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) self.element.units.padding[side].value,
self.element.units.padding[side].unit,
newViewportWidth,
newViewportHeight,
parentSize
)
end end
-- If unit is "px" and not using shorthand, value stays the same -- If unit is "px" and not using shorthand, value stays the same
end end
@@ -1152,8 +1439,13 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
elseif self.element.units.margin[side].unit ~= "px" then elseif self.element.units.margin[side].unit ~= "px" then
-- Recalculate non-pixel units -- Recalculate non-pixel units
local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth
self.element.margin[side] = self.element.margin[side] = Units.resolve(
Units.resolve(self.element.units.margin[side].value, self.element.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) self.element.units.margin[side].value,
self.element.units.margin[side].unit,
newViewportWidth,
newViewportHeight,
parentSize
)
end end
-- If unit is "px" and not using shorthand, value stays the same -- If unit is "px" and not using shorthand, value stays the same
end 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 if self.element.units.width.unit ~= "auto" and self.element.units.width.unit ~= "px" then
-- _borderBoxWidth was recalculated for viewport/percentage units -- _borderBoxWidth was recalculated for viewport/percentage units
-- Calculate content width by subtracting padding -- 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 elseif self.element.units.width.unit == "auto" then
-- For auto-sized elements, width is content width (calculated in resize method) -- For auto-sized elements, width is content width (calculated in resize method)
-- Update border-box to include padding -- 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 if self.element.units.height.unit ~= "auto" and self.element.units.height.unit ~= "px" then
-- _borderBoxHeight was recalculated for viewport/percentage units -- _borderBoxHeight was recalculated for viewport/percentage units
-- Calculate content height by subtracting padding -- 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 elseif self.element.units.height.unit == "auto" then
-- For auto-sized elements, height is content height (calculated in resize method) -- For auto-sized elements, height is content height (calculated in resize method)
-- Update border-box to include padding -- 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 -- 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 allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
local uniformWidth = allBorders and local uniformWidth = allBorders
type(self.border.top) == "number" and and type(self.border.top) == "number"
self.border.top == self.border.right and and self.border.top == self.border.right
self.border.top == self.border.bottom and and self.border.top == self.border.bottom
self.border.top == self.border.left and self.border.top == self.border.left
if uniformWidth then if uniformWidth then
-- Draw complete rounded rectangle border with uniform width -- Draw complete rounded rectangle border with uniform width
@@ -927,7 +927,7 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
-- Calculate knob offset (element overrides theme) -- Calculate knob offset (element overrides theme)
local knobOffsetX = 0 local knobOffsetX = 0
local knobOffsetY = 0 local knobOffsetY = 0
-- Use element offset if provided, otherwise use theme offset -- Use element offset if provided, otherwise use theme offset
if element.scrollbarKnobOffset then if element.scrollbarKnobOffset then
knobOffsetX = element.scrollbarKnobOffset.x or 0 knobOffsetX = element.scrollbarKnobOffset.x or 0
@@ -938,28 +938,30 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
knobOffsetY = themeOffset.vertical knobOffsetY = themeOffset.vertical
end 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 -- Draw track (frame) if component exists
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
self._NinePatch.draw( self._NinePatch.draw(frameComponent, frameComponent._loadedAtlas, trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight)
frameComponent,
frameComponent._loadedAtlas,
trackX,
trackY,
element.scrollbarWidth,
dims.vertical.trackHeight
)
end end
-- Draw thumb (bar) if component exists -- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then if barComponent and barComponent._loadedAtlas and barComponent.regions then
self._NinePatch.draw( -- Adjust knob dimensions to account for frame's contentPadding
barComponent, -- Vertical scrollbar: width affected by left+right, height affected by top+bottom
barComponent._loadedAtlas, local knobWidth = element.scrollbarWidth
trackX + knobOffsetX, local knobHeight = dims.vertical.thumbHeight - framePaddingTop / 2
trackY + dims.vertical.thumbY + knobOffsetY, self._NinePatch.draw(barComponent, barComponent._loadedAtlas, trackX + knobOffsetX, trackY + dims.vertical.thumbY + knobOffsetY, knobWidth, knobHeight)
element.scrollbarWidth,
dims.vertical.thumbHeight
)
end end
else else
-- Fallback to color-based rendering -- Fallback to color-based rendering
@@ -1002,7 +1004,7 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
-- Calculate knob offset (element overrides theme) -- Calculate knob offset (element overrides theme)
local knobOffsetX = 0 local knobOffsetX = 0
local knobOffsetY = 0 local knobOffsetY = 0
-- Use element offset if provided, otherwise use theme offset -- Use element offset if provided, otherwise use theme offset
if element.scrollbarKnobOffset then if element.scrollbarKnobOffset then
knobOffsetX = element.scrollbarKnobOffset.horizontal or 0 knobOffsetX = element.scrollbarKnobOffset.horizontal or 0
@@ -1013,27 +1015,36 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
knobOffsetY = themeOffset.y knobOffsetY = themeOffset.y
end 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 -- Draw track (frame) if component exists
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
self._NinePatch.draw( self._NinePatch.draw(frameComponent, frameComponent._loadedAtlas, trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth)
frameComponent,
frameComponent._loadedAtlas,
trackX,
trackY,
dims.horizontal.trackWidth,
element.scrollbarWidth
)
end end
-- Draw thumb (bar) if component exists -- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then 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( self._NinePatch.draw(
barComponent, barComponent,
barComponent._loadedAtlas, barComponent._loadedAtlas,
trackX + dims.horizontal.thumbX + knobOffsetX, trackX + dims.horizontal.thumbX + knobOffsetX,
trackY + knobOffsetY, trackY + knobOffsetY,
dims.horizontal.thumbWidth, knobWidth,
element.scrollbarWidth knobHeight
) )
end end
else else

View File

@@ -8,9 +8,12 @@
---@field scrollbarRadius number -- Border radius for scrollbars ---@field scrollbarRadius number -- Border radius for scrollbars
---@field scrollbarPadding number -- Padding around scrollbar ---@field scrollbarPadding number -- Padding around scrollbar
---@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit) ---@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 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 scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean} ---@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 touchScrollEnabled boolean -- Enable touch scrolling
---@field momentumScrollEnabled boolean -- Enable momentum scrolling ---@field momentumScrollEnabled boolean -- Enable momentum scrolling
---@field bounceEnabled boolean -- Enable bounce effects at boundaries ---@field bounceEnabled boolean -- Enable bounce effects at boundaries
@@ -83,6 +86,7 @@ function ScrollManager.new(config, deps)
self.scrollbarRadius = config.scrollbarRadius or 6 self.scrollbarRadius = config.scrollbarRadius or 6
self.scrollbarPadding = config.scrollbarPadding or 2 self.scrollbarPadding = config.scrollbarPadding or 2
self.scrollSpeed = config.scrollSpeed or 20 self.scrollSpeed = config.scrollSpeed or 20
self.invertScroll = config.invertScroll or false
self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default)
-- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical} -- 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} -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) 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 -- Touch scrolling configuration
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
@@ -144,6 +154,36 @@ function ScrollManager.new(config, deps)
return self return self
end 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 --- Detect if content overflows container bounds
---@param element Element The parent Element instance ---@param element Element The parent Element instance
function ScrollManager:detectOverflow(element) function ScrollManager:detectOverflow(element)
@@ -197,6 +237,14 @@ function ScrollManager:detectOverflow(element)
local containerWidth = element.width - element.padding.left - element.padding.right local containerWidth = element.width - element.padding.left - element.padding.right
local containerHeight = element.height - element.padding.top - element.padding.bottom 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._overflowX = self._contentWidth > containerWidth
self._overflowY = self._contentHeight > containerHeight self._overflowY = self._contentHeight > containerHeight
@@ -582,6 +630,9 @@ function ScrollManager:handleWheel(x, y)
-- Vertical scrolling -- Vertical scrolling
if y ~= 0 and hasVerticalOverflow then if y ~= 0 and hasVerticalOverflow then
local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up 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 if self.smoothScrollEnabled then
-- Set target for smooth scrolling instead of instant jump -- Set target for smooth scrolling instead of instant jump
self._targetScrollY = self._utils.clamp((self._targetScrollY or self._scrollY) + delta, 0, self._maxScrollY) 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 -- Horizontal scrolling
if x ~= 0 and hasHorizontalOverflow then if x ~= 0 and hasHorizontalOverflow then
local delta = -x * self.scrollSpeed local delta = -x * self.scrollSpeed
if self.invertScroll then
delta = -delta -- Invert scroll direction if enabled
end
if self.smoothScrollEnabled then if self.smoothScrollEnabled then
-- Set target for smooth scrolling instead of instant jump -- Set target for smooth scrolling instead of instant jump
self._targetScrollX = self._utils.clamp((self._targetScrollX or self._scrollX) + delta, 0, self._maxScrollX) 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, _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false,
scrollBarStyle = self.scrollBarStyle, scrollBarStyle = self.scrollBarStyle,
scrollbarKnobOffset = self.scrollbarKnobOffset, scrollbarKnobOffset = self.scrollbarKnobOffset,
scrollbarPlacement = self.scrollbarPlacement,
scrollbarBalance = self.scrollbarBalance,
_overflowX = self._overflowX, _overflowX = self._overflowX,
_overflowY = self._overflowY, _overflowY = self._overflowY,
_contentWidth = self._contentWidth, _contentWidth = self._contentWidth,
@@ -744,6 +800,14 @@ function ScrollManager:setState(state)
self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0) self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0)
end 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 if state._overflowX ~= nil then
self._overflowX = state._overflowX self._overflowX = state._overflowX
end end

View File

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

View File

@@ -67,6 +67,10 @@ local AnimationProps = {}
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH) ---@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 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 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 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 alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events ---@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 scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) ---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20) ---@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 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 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 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 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 imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display ---@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_test.lua",
"testing/__tests__/element_mode_override_test.lua", "testing/__tests__/element_mode_override_test.lua",
"testing/__tests__/event_handler_test.lua", "testing/__tests__/event_handler_test.lua",
"testing/__tests__/flex_grow_shrink_test.lua",
"testing/__tests__/flexlove_test.lua", "testing/__tests__/flexlove_test.lua",
"testing/__tests__/grid_test.lua", "testing/__tests__/grid_test.lua",
"testing/__tests__/image_cache_test.lua", "testing/__tests__/image_cache_test.lua",
@@ -62,6 +63,7 @@ local testFiles = {
"testing/__tests__/retained_prop_stability_test.lua", "testing/__tests__/retained_prop_stability_test.lua",
"testing/__tests__/roundedrect_test.lua", "testing/__tests__/roundedrect_test.lua",
"testing/__tests__/scroll_manager_test.lua", "testing/__tests__/scroll_manager_test.lua",
"testing/__tests__/scrollbar_placement_test.lua",
"testing/__tests__/text_editor_test.lua", "testing/__tests__/text_editor_test.lua",
"testing/__tests__/theme_test.lua", "testing/__tests__/theme_test.lua",
"testing/__tests__/touch_events_test.lua", "testing/__tests__/touch_events_test.lua",