scrolling improvements

This commit is contained in:
Michael Freno
2025-12-05 19:49:57 -05:00
parent c59f7c5661
commit f4dc92907c
10 changed files with 619 additions and 52 deletions

View File

@@ -841,7 +841,6 @@ end
---@param dy number
function flexlove.wheelmoved(dx, dy)
local mx, my = love.mouse.getPosition()
local function findScrollableAtPosition(elements, x, y)
for i = #elements, 1, -1 do
local element = elements[i]
@@ -871,7 +870,6 @@ function flexlove.wheelmoved(dx, dy)
end
if flexlove._immediateMode then
-- Find topmost scrollable element at mouse position using z-index ordering
for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i]
@@ -939,14 +937,14 @@ function flexlove.wheelmoved(dx, dy)
if not isClipped then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") and (element._overflowX or element._overflowY) then
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element:_handleWheelScroll(dx, dy)
-- Save scroll position to StateManager immediately in immediate mode
if element._stateId then
if element._stateId and element._scrollManager then
local scrollManagerState = element._scrollManager:getState()
StateManager.updateState(element._stateId, {
_scrollX = element._scrollX,
_scrollY = element._scrollY,
scrollManager = scrollManagerState,
})
end
return

View File

@@ -132,6 +132,7 @@
---@field scrollbarRadius number? -- Scrollbar corner radius
---@field scrollbarPadding number? -- Scrollbar padding from edges
---@field scrollSpeed number? -- Scroll speed multiplier
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
---@field _overflowX boolean? -- Internal: whether content overflows horizontally
---@field _overflowY boolean? -- Internal: whether content overflows vertically
---@field _contentWidth number? -- Internal: total content width
@@ -1423,6 +1424,8 @@ function Element.new(props)
scrollbarRadius = props.scrollbarRadius,
scrollbarPadding = props.scrollbarPadding,
scrollSpeed = props.scrollSpeed,
smoothScrollEnabled = props.smoothScrollEnabled,
scrollBarStyle = props.scrollBarStyle,
hideScrollbars = props.hideScrollbars,
_scrollX = props._scrollX,
_scrollY = props._scrollY,
@@ -1438,6 +1441,7 @@ function Element.new(props)
self.scrollbarRadius = self._scrollManager.scrollbarRadius
self.scrollbarPadding = self._scrollManager.scrollbarPadding
self.scrollSpeed = self._scrollManager.scrollSpeed
self.scrollBarStyle = self._scrollManager.scrollBarStyle
self.hideScrollbars = self._scrollManager.hideScrollbars
-- Initialize state properties (will be synced from ScrollManager)
@@ -2212,6 +2216,12 @@ function Element:update(dt)
self._textEditor:update(self, dt)
end
-- Update scroll manager for smooth scrolling and momentum
if self._scrollManager then
self._scrollManager:update(dt)
self:_syncScrollManagerState()
end
-- Update animation if exists
if self.animation then
-- Ensure animation has Color module reference for color interpolation

View File

@@ -266,6 +266,14 @@ function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Handle scrollbar drag if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
element:_handleScrollbarDrag(mx, my)
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
return -- Don't process other drag events while dragging scrollbar
end
-- Mouse has moved - fire drag event only if still hovering
if isHovering then
local modifiers = EventHandler._utils.getModifiers()
@@ -304,6 +312,16 @@ function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
-- Handle scrollbar release if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
element:_handleScrollbarRelease(button)
self._scrollbarPressHandled = false -- Reset flag
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
return -- Don't process click events for scrollbar release
end
-- Determine click count (double-click detection)
local clickCount = 1
local doubleClickThreshold = 0.3 -- 300ms for double-click

View File

@@ -880,6 +880,12 @@ end
---@param h number Height
---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions
function Renderer:drawScrollbars(element, x, y, w, h, dims)
-- Try to get themed scrollbar component
local scrollbarComponent = nil
if element.scrollBarStyle or self._Theme.hasActive() then
scrollbarComponent = self._Theme.getScrollbar(element.scrollBarStyle)
end
-- Vertical scrollbar
if dims.vertical.visible and not element.hideScrollbars.vertical then
-- Position scrollbar within content area (x, y is border-box origin)
@@ -888,6 +894,37 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding
local trackY = contentY + element.scrollbarPadding
-- Check if we should use themed rendering
if scrollbarComponent then
-- Themed scrollbar rendering using NinePatch
local frameComponent = scrollbarComponent.frame or scrollbarComponent
local barComponent = scrollbarComponent.bar or scrollbarComponent
-- 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
)
end
-- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then
self._NinePatch.draw(
barComponent,
barComponent._loadedAtlas,
trackX,
trackY + dims.vertical.thumbY,
element.scrollbarWidth,
dims.vertical.thumbHeight
)
end
else
-- Fallback to color-based rendering
-- Determine thumb color based on state (independent for vertical)
local thumbColor = element.scrollbarColor
if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then
@@ -908,6 +945,7 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, element.scrollbarWidth, dims.vertical.thumbHeight, element.scrollbarRadius)
end
end
-- Horizontal scrollbar
if dims.horizontal.visible and not element.hideScrollbars.horizontal then
@@ -917,6 +955,37 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
local trackX = contentX + element.scrollbarPadding
local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding
-- Check if we should use themed rendering
if scrollbarComponent then
-- Themed scrollbar rendering using NinePatch
local frameComponent = scrollbarComponent.frame or scrollbarComponent
local barComponent = scrollbarComponent.bar or scrollbarComponent
-- 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
)
end
-- Draw thumb (bar) if component exists
if barComponent and barComponent._loadedAtlas and barComponent.regions then
self._NinePatch.draw(
barComponent,
barComponent._loadedAtlas,
trackX + dims.horizontal.thumbX,
trackY,
dims.horizontal.thumbWidth,
element.scrollbarWidth
)
end
else
-- Fallback to color-based rendering
-- Determine thumb color based on state (independent for horizontal)
local thumbColor = element.scrollbarColor
if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then
@@ -937,6 +1006,7 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, element.scrollbarWidth, element.scrollbarRadius)
end
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)

View File

@@ -8,6 +8,7 @@
---@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 scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
---@field touchScrollEnabled boolean -- Enable touch scrolling
---@field momentumScrollEnabled boolean -- Enable momentum scrolling
@@ -21,6 +22,9 @@
---@field _contentHeight number -- Total content height (including overflow)
---@field _scrollX number -- Current horizontal scroll position
---@field _scrollY number -- Current vertical scroll position
---@field _targetScrollX number? -- Target scroll X for smooth scrolling
---@field _targetScrollY number? -- Target scroll Y for smooth scrolling
---@field _smoothScrollSpeed number -- Speed of smooth scroll interpolation (0-1, higher = faster)
---@field _maxScrollX number -- Maximum horizontal scroll (contentWidth - containerWidth)
---@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight)
---@field _scrollbarHoveredVertical boolean -- True if mouse is over vertical scrollbar
@@ -74,6 +78,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.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default)
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false)
@@ -95,6 +100,10 @@ function ScrollManager.new(config, deps)
-- Scroll state (can be restored from config in immediate mode)
self._scrollX = config._scrollX or 0
self._scrollY = config._scrollY or 0
self._targetScrollX = nil
self._targetScrollY = nil
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster)
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
self._maxScrollX = 0
self._maxScrollY = 0
@@ -552,24 +561,38 @@ function ScrollManager:handleWheel(x, y)
return false
end
local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0
local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0
-- In immediate mode, overflow might not be calculated yet, so allow scrolling based on maxScroll values
-- If _overflowY is nil/false but _maxScrollY > 0, we should still allow scrolling (from restored state)
local hasVerticalOverflow = (self._overflowY and self._maxScrollY > 0) or (self._maxScrollY and self._maxScrollY > 0)
local hasHorizontalOverflow = (self._overflowX and self._maxScrollX > 0) or (self._maxScrollX and self._maxScrollX > 0)
local scrolled = false
-- Vertical scrolling
if y ~= 0 and hasVerticalOverflow then
local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up
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)
else
-- Instant scrolling (default behavior)
local newScrollY = self._scrollY + delta
self:setScroll(nil, newScrollY)
end
scrolled = true
end
-- Horizontal scrolling
if x ~= 0 and hasHorizontalOverflow then
local delta = -x * self.scrollSpeed
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)
else
-- Instant scrolling (default behavior)
local newScrollX = self._scrollX + delta
self:setScroll(newScrollX, nil)
end
scrolled = true
end
@@ -619,11 +642,18 @@ function ScrollManager:getState()
return {
_scrollX = self._scrollX or 0,
_scrollY = self._scrollY or 0,
_targetScrollX = self._targetScrollX,
_targetScrollY = self._targetScrollY,
_scrollbarDragging = self._scrollbarDragging or false,
_hoveredScrollbar = self._hoveredScrollbar,
_scrollbarDragOffset = self._scrollbarDragOffset or 0,
_scrollbarHoveredVertical = self._scrollbarHoveredVertical or false,
_scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false,
scrollBarStyle = self.scrollBarStyle,
_overflowX = self._overflowX,
_overflowY = self._overflowY,
_contentWidth = self._contentWidth,
_contentHeight = self._contentHeight,
}
end
@@ -672,6 +702,34 @@ function ScrollManager:setState(state)
if state._scrollbarHoveredHorizontal ~= nil then
self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal
end
if state.scrollBarStyle ~= nil then
self.scrollBarStyle = state.scrollBarStyle
end
if state._overflowX ~= nil then
self._overflowX = state._overflowX
end
if state._overflowY ~= nil then
self._overflowY = state._overflowY
end
if state._contentWidth ~= nil then
self._contentWidth = state._contentWidth
end
if state._contentHeight ~= nil then
self._contentHeight = state._contentHeight
end
if state._targetScrollX ~= nil then
self._targetScrollX = state._targetScrollX
end
if state._targetScrollY ~= nil then
self._targetScrollY = state._targetScrollY
end
end
--- Handle touch press for scrolling
@@ -795,6 +853,29 @@ end
--- Update momentum scrolling (call every frame with dt)
---@param dt number Delta time in seconds
function ScrollManager:update(dt)
-- Smooth scroll interpolation
if self._targetScrollX or self._targetScrollY then
if self._targetScrollY then
local diff = self._targetScrollY - self._scrollY
if math.abs(diff) > 0.5 then
self._scrollY = self._scrollY + diff * self._smoothScrollSpeed
else
self._scrollY = self._targetScrollY
self._targetScrollY = nil
end
end
if self._targetScrollX then
local diff = self._targetScrollX - self._scrollX
if math.abs(diff) > 0.5 then
self._scrollX = self._scrollX + diff * self._smoothScrollSpeed
else
self._scrollX = self._targetScrollX
self._targetScrollX = nil
end
end
end
if not self._momentumScrolling then
-- Handle bounce back if overscrolled
if self.bounceEnabled then

View File

@@ -64,6 +64,10 @@ local function validateThemeDefinition(definition)
return false, "Theme 'fonts' must be a table"
end
if definition.scrollbars and type(definition.scrollbars) ~= "table" then
return false, "Theme 'scrollbars' must be a table"
end
return true, nil
end
@@ -331,6 +335,7 @@ end
---@field name string
---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component)
---@field components table<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent>? -- Optional: scrollbar component definitions (uses ThemeComponent format)
---@field colors table<string, Color>?
---@field fonts table<string, string>? -- Optional: font family definitions (name -> path)
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions
@@ -340,6 +345,7 @@ end
---@field atlas love.Image? -- Optional: global atlas
---@field atlasData love.ImageData?
---@field components table<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent> -- Scrollbar component definitions
---@field colors table<string, Color>
---@field fonts table<string, string> -- Font family definitions
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions
@@ -409,6 +415,7 @@ function Theme.new(definition)
end
self.components = definition.components or {}
self.scrollbars = definition.scrollbars or {}
self.colors = definition.colors or {}
self.fonts = definition.fonts or {}
self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil
@@ -552,6 +559,84 @@ function Theme.new(definition)
end
end
-- Load scrollbar-specific atlases and process 9-patch definitions
-- Scrollbars can have 'bar' and 'frame' subcomponents
for scrollbarName, scrollbarDef in pairs(self.scrollbars) do
-- Handle scrollbar definitions with bar/frame subcomponents
if scrollbarDef.bar or scrollbarDef.frame then
-- Process 'bar' subcomponent
if scrollbarDef.bar then
if type(scrollbarDef.bar) == "string" then
-- Convert string path to ThemeComponent structure
local barComponent = { atlas = scrollbarDef.bar }
loadAtlasWithNinePatch(barComponent, scrollbarDef.bar, "for scrollbar '" .. scrollbarName .. ".bar'")
if barComponent.insets then
createRegionsFromInsets(barComponent, barComponent._loadedAtlas or self.atlas)
end
scrollbarDef.bar = barComponent
elseif type(scrollbarDef.bar) == "table" then
-- Already a ThemeComponent structure, process it
if scrollbarDef.bar.atlas and type(scrollbarDef.bar.atlas) == "string" then
loadAtlasWithNinePatch(scrollbarDef.bar, scrollbarDef.bar.atlas, "for scrollbar '" .. scrollbarName .. ".bar'")
end
if scrollbarDef.bar.insets then
createRegionsFromInsets(scrollbarDef.bar, scrollbarDef.bar._loadedAtlas or self.atlas)
end
end
end
-- Process 'frame' subcomponent
if scrollbarDef.frame then
if type(scrollbarDef.frame) == "string" then
-- Convert string path to ThemeComponent structure
local frameComponent = { atlas = scrollbarDef.frame }
loadAtlasWithNinePatch(frameComponent, scrollbarDef.frame, "for scrollbar '" .. scrollbarName .. ".frame'")
if frameComponent.insets then
createRegionsFromInsets(frameComponent, frameComponent._loadedAtlas or self.atlas)
end
scrollbarDef.frame = frameComponent
elseif type(scrollbarDef.frame) == "table" then
-- Already a ThemeComponent structure, process it
if scrollbarDef.frame.atlas and type(scrollbarDef.frame.atlas) == "string" then
loadAtlasWithNinePatch(scrollbarDef.frame, scrollbarDef.frame.atlas, "for scrollbar '" .. scrollbarName .. ".frame'")
end
if scrollbarDef.frame.insets then
createRegionsFromInsets(scrollbarDef.frame, scrollbarDef.frame._loadedAtlas or self.atlas)
end
end
end
else
-- Treat as a single ThemeComponent (no bar/frame split)
if scrollbarDef.atlas then
if type(scrollbarDef.atlas) == "string" then
loadAtlasWithNinePatch(scrollbarDef, scrollbarDef.atlas, "for scrollbar '" .. scrollbarName .. "'")
else
scrollbarDef._loadedAtlas = scrollbarDef.atlas
end
end
if scrollbarDef.insets then
createRegionsFromInsets(scrollbarDef, self.atlas)
end
if scrollbarDef.states then
for stateName, stateComponent in pairs(scrollbarDef.states) do
if stateComponent.atlas then
if type(stateComponent.atlas) == "string" then
loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "'")
else
stateComponent._loadedAtlas = stateComponent.atlas
end
end
if stateComponent.insets then
createRegionsFromInsets(stateComponent, scrollbarDef._loadedAtlas or self.atlas)
end
end
end
end
end
return self
end
@@ -646,6 +731,50 @@ function Theme.getComponent(componentName, state)
return component
end
--- Get the first (default) scrollbar from the active theme
--- Returns the first scrollbar component in insertion order
---@return ThemeComponent? scrollbar Returns first scrollbar component or nil if no scrollbars defined
function Theme.getDefaultScrollbar()
if not activeTheme or not activeTheme.scrollbars then
return nil
end
-- Return first scrollbar in insertion order (Lua 5.3+ preserves order)
for _, scrollbar in pairs(activeTheme.scrollbars) do
return scrollbar
end
return nil
end
--- Retrieve themed scrollbar components for consistent scrollbar styling
--- Use this to apply theme-based scrollbar appearance to scrollable elements
---@param scrollbarName string? Name of the scrollbar style (e.g., "v1", "v2"). If nil, returns default (first) scrollbar
---@param state string? Optional state name (e.g., "hover", "pressed") - currently unused for scrollbars
---@return ThemeComponent? scrollbar Returns scrollbar component or nil if not found
function Theme.getScrollbar(scrollbarName, state)
if not activeTheme or not activeTheme.scrollbars then
return nil
end
-- If no scrollbarName specified, return default (first) scrollbar
if not scrollbarName then
return Theme.getDefaultScrollbar()
end
local scrollbar = activeTheme.scrollbars[scrollbarName]
if not scrollbar then
return nil
end
-- Check for state-specific override (if scrollbar supports states in the future)
if state and scrollbar.states and scrollbar.states[state] then
return scrollbar.states[state]
end
return scrollbar
end
--- Access theme-defined fonts for consistent typography across your UI
--- Use this to load fonts specified in your theme definition
---@param fontName string Name of the font family (e.g., "default", "heading")
@@ -889,6 +1018,26 @@ function ThemeManager:getStateComponent()
return component
end
---Get a scrollbar component from the theme
---@param scrollbarName string? The scrollbar style name (e.g., "v1", "v2"). If nil, returns default (first) scrollbar
---@return ThemeComponent? scrollbar The scrollbar component, or nil if not found
function ThemeManager:getScrollbarComponent(scrollbarName)
local themeToUse = self:getTheme()
if not themeToUse or not themeToUse.scrollbars or type(themeToUse.scrollbars) ~= "table" then
return nil
end
-- If no scrollbarName specified, return default (first) scrollbar
if not scrollbarName then
for _, scrollbar in pairs(themeToUse.scrollbars) do
return scrollbar
end
return nil
end
return themeToUse.scrollbars[scrollbarName]
end
---Get a style property from the current state component
---@param property string The property name
---@return any? value The property value, or nil if not found
@@ -1219,6 +1368,69 @@ function Theme.validateTheme(theme, options)
end
end
-- Scrollbars validation (optional)
if theme.scrollbars ~= nil then
if type(theme.scrollbars) ~= "table" then
table.insert(errors, "Theme 'scrollbars' must be a table")
else
for scrollbarName, scrollbarDef in pairs(theme.scrollbars) do
if type(scrollbarDef) == "table" then
-- Check if it has bar/frame subcomponents
if scrollbarDef.bar or scrollbarDef.frame then
-- Validate bar subcomponent
if scrollbarDef.bar ~= nil then
if type(scrollbarDef.bar) ~= "string" and type(scrollbarDef.bar) ~= "table" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' bar must be a string or table")
end
end
-- Validate frame subcomponent
if scrollbarDef.frame ~= nil then
if type(scrollbarDef.frame) ~= "string" and type(scrollbarDef.frame) ~= "table" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' frame must be a string or table")
end
end
else
-- Validate as a single ThemeComponent
-- Validate atlas if present
if scrollbarDef.atlas ~= nil and type(scrollbarDef.atlas) ~= "string" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' atlas must be a string")
end
-- Validate insets if present
if scrollbarDef.insets ~= nil then
if type(scrollbarDef.insets) ~= "table" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must be a table")
else
for _, side in ipairs({ "left", "top", "right", "bottom" }) do
if scrollbarDef.insets[side] == nil then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must have '" .. side .. "' field")
elseif type(scrollbarDef.insets[side]) ~= "number" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be a number")
elseif scrollbarDef.insets[side] < 0 then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be non-negative")
end
end
end
end
-- Validate states if present
if scrollbarDef.states ~= nil then
if type(scrollbarDef.states) ~= "table" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' states must be a table")
else
for stateName, stateComponent in pairs(scrollbarDef.states) do
if type(stateComponent) ~= "table" then
table.insert(errors, "Scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "' must be a table")
end
end
end
end
end
end
end
end
end
-- contentAutoSizingMultiplier validation (optional)
if theme.contentAutoSizingMultiplier ~= nil then
if type(theme.contentAutoSizingMultiplier) ~= "table" then
@@ -1254,6 +1466,7 @@ function Theme.validateTheme(theme, options)
name = true,
atlas = true,
components = true,
scrollbars = true,
colors = true,
fonts = true,
contentAutoSizingMultiplier = true,
@@ -1330,6 +1543,11 @@ function Theme.sanitizeTheme(theme)
sanitized.components = theme.components
end
-- Sanitize scrollbars (preserve as-is, they're complex like components)
if type(theme.scrollbars) == "table" then
sanitized.scrollbars = theme.scrollbars
end
-- Sanitize contentAutoSizingMultiplier
if type(theme.contentAutoSizingMultiplier) == "table" then
sanitized.contentAutoSizingMultiplier = {}

View File

@@ -121,6 +121,8 @@ 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 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 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

View File

@@ -1302,6 +1302,166 @@ function TestFlexLoveUnhappyPaths:testImmediateModeFrameEdgeCases()
luaunit.assertTrue(true)
end
-- Test: scrollSpeed prop is properly passed to ScrollManager in immediate mode
function TestFlexLove:testScrollSpeedInImmediateMode()
FlexLove.setMode("immediate")
FlexLove.beginFrame()
local element = FlexLove.new({
id = "scrollableElement",
width = 200,
height = 200,
overflow = "auto",
scrollSpeed = 75, -- Custom scroll speed
})
-- Add children to make it scrollable
for i = 1, 10 do
FlexLove.new({
parent = element,
width = 180,
height = 50,
})
end
FlexLove.endFrame()
-- Verify scrollSpeed was set correctly
luaunit.assertEquals(element.scrollSpeed, 75)
luaunit.assertNotNil(element._scrollManager)
luaunit.assertEquals(element._scrollManager.scrollSpeed, 75)
-- Test another frame to ensure scrollSpeed persists
FlexLove.beginFrame()
local element2 = FlexLove.new({
id = "scrollableElement",
width = 200,
height = 200,
overflow = "auto",
scrollSpeed = 75,
})
for i = 1, 10 do
FlexLove.new({
parent = element2,
width = 180,
height = 50,
})
end
FlexLove.endFrame()
-- Verify scrollSpeed is still correct after recreating element
luaunit.assertEquals(element2.scrollSpeed, 75)
luaunit.assertEquals(element2._scrollManager.scrollSpeed, 75)
end
-- Test: smoothScrollEnabled prop is properly passed to ScrollManager
function TestFlexLove:testSmoothScrollEnabledProp()
FlexLove.setMode("immediate")
FlexLove.beginFrame()
local element = FlexLove.new({
id = "smoothScrollElement",
width = 200,
height = 200,
overflow = "auto",
smoothScrollEnabled = true,
})
for i = 1, 10 do
FlexLove.new({
parent = element,
width = 180,
height = 50,
})
end
FlexLove.endFrame()
-- Verify smoothScrollEnabled was set correctly
luaunit.assertNotNil(element._scrollManager)
luaunit.assertTrue(element._scrollManager.smoothScrollEnabled)
end
-- Test: scrollSpeed must be provided every frame in immediate mode
function TestFlexLove:testScrollSpeedMustBeProvidedEveryFrame()
FlexLove.setMode("immediate")
-- Frame 1: Set custom scrollSpeed
FlexLove.beginFrame()
local element1 = FlexLove.new({
id = "scrollSpeedTest",
width = 200,
height = 200,
overflow = "auto",
scrollSpeed = 50,
})
for i = 1, 10 do
FlexLove.new({ parent = element1, width = 180, height = 50 })
end
FlexLove.endFrame()
luaunit.assertEquals(element1._scrollManager.scrollSpeed, 50)
-- Frame 2: Forget to provide scrollSpeed (should default to 20)
FlexLove.beginFrame()
local element2 = FlexLove.new({
id = "scrollSpeedTest",
width = 200,
height = 200,
overflow = "auto",
-- scrollSpeed NOT provided - will default to 20
})
for i = 1, 10 do
FlexLove.new({ parent = element2, width = 180, height = 50 })
end
FlexLove.endFrame()
-- In immediate mode, props must be provided every frame
luaunit.assertEquals(element2._scrollManager.scrollSpeed, 20)
end
-- Test: smooth scrolling actually interpolates scroll position
function TestFlexLove:testSmoothScrollingInterpolation()
FlexLove.setMode("retained")
local element = FlexLove.new({
width = 200,
height = 200,
overflow = "auto",
smoothScrollEnabled = true,
})
for i = 1, 20 do
FlexLove.new({
parent = element,
width = 180,
height = 50,
})
end
-- Manually set overflow state (normally done by layout)
element._scrollManager._overflowY = true
element._scrollManager._maxScrollY = 800 -- 20 * 50 - 200
-- Trigger wheel scroll
element:_handleWheelScroll(0, -1) -- Scroll down
-- Should set target, not immediate scroll
luaunit.assertNotNil(element._scrollManager._targetScrollY)
local initialScroll = element._scrollManager._scrollY
local targetScroll = element._scrollManager._targetScrollY
-- Initial scroll should be 0, target should be scrollSpeed (default 20)
luaunit.assertEquals(initialScroll, 0)
luaunit.assertEquals(targetScroll, 20)
-- Update should interpolate towards target
element:update(0.016) -- One frame at 60fps
local afterUpdate = element._scrollManager._scrollY
-- Scroll position should have moved towards target
luaunit.assertTrue(afterUpdate > initialScroll)
luaunit.assertTrue(afterUpdate <= targetScroll)
end
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

View File

@@ -3,6 +3,16 @@ local Color = require("libs.FlexLove").Color
return {
name = "Metal Theme",
contentAutoSizingMultiplier = { width = 1.05, height = 1.1 },
scrollbars = {
v1 = {
bar = "themes/metal/Button/Button01a_1.9.png",
frame = "themes/metal/Frame/Frame01a.9.png",
},
v2 = {
bar = "themes/metal/Button/Button01a_1.9.png",
frame = "themes/metal/Frame/Frame01a.9.png",
},
},
components = {
framev1 = {
atlas = "themes/metal/Frame/Frame01a.9.png",