scrolling improvements
This commit is contained in:
12
FlexLove.lua
12
FlexLove.lua
@@ -841,7 +841,6 @@ end
|
|||||||
---@param dy number
|
---@param dy number
|
||||||
function flexlove.wheelmoved(dx, dy)
|
function flexlove.wheelmoved(dx, dy)
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
|
|
||||||
local function findScrollableAtPosition(elements, x, y)
|
local function findScrollableAtPosition(elements, x, y)
|
||||||
for i = #elements, 1, -1 do
|
for i = #elements, 1, -1 do
|
||||||
local element = elements[i]
|
local element = elements[i]
|
||||||
@@ -871,7 +870,6 @@ function flexlove.wheelmoved(dx, dy)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if flexlove._immediateMode then
|
if flexlove._immediateMode then
|
||||||
-- Find topmost scrollable element at mouse position using z-index ordering
|
|
||||||
for i = #Context._zIndexOrderedElements, 1, -1 do
|
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||||||
local element = Context._zIndexOrderedElements[i]
|
local element = Context._zIndexOrderedElements[i]
|
||||||
|
|
||||||
@@ -939,14 +937,14 @@ function flexlove.wheelmoved(dx, dy)
|
|||||||
if not isClipped then
|
if not isClipped then
|
||||||
local overflowX = element.overflowX or element.overflow
|
local overflowX = element.overflowX or element.overflow
|
||||||
local overflowY = element.overflowY 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)
|
element:_handleWheelScroll(dx, dy)
|
||||||
|
|
||||||
-- Save scroll position to StateManager immediately in immediate mode
|
if element._stateId and element._scrollManager then
|
||||||
if element._stateId then
|
local scrollManagerState = element._scrollManager:getState()
|
||||||
StateManager.updateState(element._stateId, {
|
StateManager.updateState(element._stateId, {
|
||||||
_scrollX = element._scrollX,
|
scrollManager = scrollManagerState,
|
||||||
_scrollY = element._scrollY,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
---@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 scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
|
||||||
---@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
|
||||||
@@ -1423,6 +1424,8 @@ function Element.new(props)
|
|||||||
scrollbarRadius = props.scrollbarRadius,
|
scrollbarRadius = props.scrollbarRadius,
|
||||||
scrollbarPadding = props.scrollbarPadding,
|
scrollbarPadding = props.scrollbarPadding,
|
||||||
scrollSpeed = props.scrollSpeed,
|
scrollSpeed = props.scrollSpeed,
|
||||||
|
smoothScrollEnabled = props.smoothScrollEnabled,
|
||||||
|
scrollBarStyle = props.scrollBarStyle,
|
||||||
hideScrollbars = props.hideScrollbars,
|
hideScrollbars = props.hideScrollbars,
|
||||||
_scrollX = props._scrollX,
|
_scrollX = props._scrollX,
|
||||||
_scrollY = props._scrollY,
|
_scrollY = props._scrollY,
|
||||||
@@ -1438,6 +1441,7 @@ 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.scrollBarStyle = self._scrollManager.scrollBarStyle
|
||||||
self.hideScrollbars = self._scrollManager.hideScrollbars
|
self.hideScrollbars = self._scrollManager.hideScrollbars
|
||||||
|
|
||||||
-- Initialize state properties (will be synced from ScrollManager)
|
-- Initialize state properties (will be synced from ScrollManager)
|
||||||
@@ -2212,6 +2216,12 @@ function Element:update(dt)
|
|||||||
self._textEditor:update(self, dt)
|
self._textEditor:update(self, dt)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Update scroll manager for smooth scrolling and momentum
|
||||||
|
if self._scrollManager then
|
||||||
|
self._scrollManager:update(dt)
|
||||||
|
self:_syncScrollManagerState()
|
||||||
|
end
|
||||||
|
|
||||||
-- Update animation if exists
|
-- Update animation if exists
|
||||||
if self.animation then
|
if self.animation then
|
||||||
-- Ensure animation has Color module reference for color interpolation
|
-- Ensure animation has Color module reference for color interpolation
|
||||||
|
|||||||
@@ -266,6 +266,14 @@ function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
|
|||||||
local lastY = self._lastMouseY[button] or my
|
local lastY = self._lastMouseY[button] or my
|
||||||
|
|
||||||
if lastX ~= mx or lastY ~= my then
|
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
|
-- Mouse has moved - fire drag event only if still hovering
|
||||||
if isHovering then
|
if isHovering then
|
||||||
local modifiers = EventHandler._utils.getModifiers()
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
@@ -304,6 +312,16 @@ function EventHandler:_handleMouseRelease(element, mx, my, button)
|
|||||||
local currentTime = love.timer.getTime()
|
local currentTime = love.timer.getTime()
|
||||||
local modifiers = EventHandler._utils.getModifiers()
|
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)
|
-- Determine click count (double-click detection)
|
||||||
local clickCount = 1
|
local clickCount = 1
|
||||||
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
||||||
|
|||||||
@@ -880,6 +880,12 @@ end
|
|||||||
---@param h number Height
|
---@param h number Height
|
||||||
---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions
|
---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions
|
||||||
function Renderer:drawScrollbars(element, x, y, w, h, dims)
|
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
|
-- Vertical scrollbar
|
||||||
if dims.vertical.visible and not element.hideScrollbars.vertical then
|
if dims.vertical.visible and not element.hideScrollbars.vertical then
|
||||||
-- Position scrollbar within content area (x, y is border-box origin)
|
-- Position scrollbar within content area (x, y is border-box origin)
|
||||||
@@ -888,25 +894,57 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
|
|||||||
local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding
|
local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding
|
||||||
local trackY = contentY + element.scrollbarPadding
|
local trackY = contentY + element.scrollbarPadding
|
||||||
|
|
||||||
-- Determine thumb color based on state (independent for vertical)
|
-- Check if we should use themed rendering
|
||||||
local thumbColor = element.scrollbarColor
|
if scrollbarComponent then
|
||||||
if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then
|
-- Themed scrollbar rendering using NinePatch
|
||||||
-- Active state: brighter
|
local frameComponent = scrollbarComponent.frame or scrollbarComponent
|
||||||
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4)
|
local barComponent = scrollbarComponent.bar or scrollbarComponent
|
||||||
thumbColor = self._Color.new(r, g, b, a)
|
|
||||||
elseif element._scrollbarHoveredVertical then
|
-- Draw track (frame) if component exists
|
||||||
-- Hover state: slightly brighter
|
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
|
||||||
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2)
|
self._NinePatch.draw(
|
||||||
thumbColor = self._Color.new(r, g, b, a)
|
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
|
||||||
|
-- Active state: brighter
|
||||||
|
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4)
|
||||||
|
thumbColor = self._Color.new(r, g, b, a)
|
||||||
|
elseif element._scrollbarHoveredVertical then
|
||||||
|
-- Hover state: slightly brighter
|
||||||
|
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2)
|
||||||
|
thumbColor = self._Color.new(r, g, b, a)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw track
|
||||||
|
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
|
||||||
|
love.graphics.rectangle("fill", trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight, element.scrollbarRadius)
|
||||||
|
|
||||||
|
-- Draw thumb with state-based color
|
||||||
|
love.graphics.setColor(thumbColor:toRGBA())
|
||||||
|
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, element.scrollbarWidth, dims.vertical.thumbHeight, element.scrollbarRadius)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw track
|
|
||||||
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight, element.scrollbarRadius)
|
|
||||||
|
|
||||||
-- Draw thumb with state-based color
|
|
||||||
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
|
-- Horizontal scrollbar
|
||||||
@@ -917,25 +955,57 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims)
|
|||||||
local trackX = contentX + element.scrollbarPadding
|
local trackX = contentX + element.scrollbarPadding
|
||||||
local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding
|
local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding
|
||||||
|
|
||||||
-- Determine thumb color based on state (independent for horizontal)
|
-- Check if we should use themed rendering
|
||||||
local thumbColor = element.scrollbarColor
|
if scrollbarComponent then
|
||||||
if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then
|
-- Themed scrollbar rendering using NinePatch
|
||||||
-- Active state: brighter
|
local frameComponent = scrollbarComponent.frame or scrollbarComponent
|
||||||
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4)
|
local barComponent = scrollbarComponent.bar or scrollbarComponent
|
||||||
thumbColor = self._Color.new(r, g, b, a)
|
|
||||||
elseif element._scrollbarHoveredHorizontal then
|
-- Draw track (frame) if component exists
|
||||||
-- Hover state: slightly brighter
|
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
|
||||||
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2)
|
self._NinePatch.draw(
|
||||||
thumbColor = self._Color.new(r, g, b, a)
|
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
|
||||||
|
-- Active state: brighter
|
||||||
|
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4)
|
||||||
|
thumbColor = self._Color.new(r, g, b, a)
|
||||||
|
elseif element._scrollbarHoveredHorizontal then
|
||||||
|
-- Hover state: slightly brighter
|
||||||
|
local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2)
|
||||||
|
thumbColor = self._Color.new(r, g, b, a)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw track
|
||||||
|
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
|
||||||
|
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth, element.scrollbarRadius)
|
||||||
|
|
||||||
|
-- Draw thumb with state-based color
|
||||||
|
love.graphics.setColor(thumbColor:toRGBA())
|
||||||
|
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, element.scrollbarWidth, element.scrollbarRadius)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw track
|
|
||||||
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth, element.scrollbarRadius)
|
|
||||||
|
|
||||||
-- Draw thumb with state-based color
|
|
||||||
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
|
-- Reset color
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
---@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 scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
|
||||||
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
|
---@field hideScrollbars table -- {vertical: boolean, horizontal: boolean}
|
||||||
---@field touchScrollEnabled boolean -- Enable touch scrolling
|
---@field touchScrollEnabled boolean -- Enable touch scrolling
|
||||||
---@field momentumScrollEnabled boolean -- Enable momentum scrolling
|
---@field momentumScrollEnabled boolean -- Enable momentum scrolling
|
||||||
@@ -21,6 +22,9 @@
|
|||||||
---@field _contentHeight number -- Total content height (including overflow)
|
---@field _contentHeight number -- Total content height (including overflow)
|
||||||
---@field _scrollX number -- Current horizontal scroll position
|
---@field _scrollX number -- Current horizontal scroll position
|
||||||
---@field _scrollY number -- Current vertical 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 _maxScrollX number -- Maximum horizontal scroll (contentWidth - containerWidth)
|
||||||
---@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight)
|
---@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight)
|
||||||
---@field _scrollbarHoveredVertical boolean -- True if mouse is over vertical scrollbar
|
---@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.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.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default)
|
||||||
|
|
||||||
-- 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)
|
||||||
@@ -95,6 +100,10 @@ function ScrollManager.new(config, deps)
|
|||||||
-- Scroll state (can be restored from config in immediate mode)
|
-- Scroll state (can be restored from config in immediate mode)
|
||||||
self._scrollX = config._scrollX or 0
|
self._scrollX = config._scrollX or 0
|
||||||
self._scrollY = config._scrollY 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._maxScrollX = 0
|
||||||
self._maxScrollY = 0
|
self._maxScrollY = 0
|
||||||
|
|
||||||
@@ -552,24 +561,38 @@ function ScrollManager:handleWheel(x, y)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0
|
-- In immediate mode, overflow might not be calculated yet, so allow scrolling based on maxScroll values
|
||||||
local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0
|
-- 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
|
local scrolled = false
|
||||||
|
|
||||||
-- 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
|
||||||
local newScrollY = self._scrollY + delta
|
if self.smoothScrollEnabled then
|
||||||
self:setScroll(nil, newScrollY)
|
-- 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
|
scrolled = true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 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
|
||||||
local newScrollX = self._scrollX + delta
|
if self.smoothScrollEnabled then
|
||||||
self:setScroll(newScrollX, nil)
|
-- 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
|
scrolled = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -619,11 +642,18 @@ function ScrollManager:getState()
|
|||||||
return {
|
return {
|
||||||
_scrollX = self._scrollX or 0,
|
_scrollX = self._scrollX or 0,
|
||||||
_scrollY = self._scrollY or 0,
|
_scrollY = self._scrollY or 0,
|
||||||
|
_targetScrollX = self._targetScrollX,
|
||||||
|
_targetScrollY = self._targetScrollY,
|
||||||
_scrollbarDragging = self._scrollbarDragging or false,
|
_scrollbarDragging = self._scrollbarDragging or false,
|
||||||
_hoveredScrollbar = self._hoveredScrollbar,
|
_hoveredScrollbar = self._hoveredScrollbar,
|
||||||
_scrollbarDragOffset = self._scrollbarDragOffset or 0,
|
_scrollbarDragOffset = self._scrollbarDragOffset or 0,
|
||||||
_scrollbarHoveredVertical = self._scrollbarHoveredVertical or false,
|
_scrollbarHoveredVertical = self._scrollbarHoveredVertical or false,
|
||||||
_scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false,
|
_scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false,
|
||||||
|
scrollBarStyle = self.scrollBarStyle,
|
||||||
|
_overflowX = self._overflowX,
|
||||||
|
_overflowY = self._overflowY,
|
||||||
|
_contentWidth = self._contentWidth,
|
||||||
|
_contentHeight = self._contentHeight,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -672,6 +702,34 @@ function ScrollManager:setState(state)
|
|||||||
if state._scrollbarHoveredHorizontal ~= nil then
|
if state._scrollbarHoveredHorizontal ~= nil then
|
||||||
self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal
|
self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
--- Handle touch press for scrolling
|
--- Handle touch press for scrolling
|
||||||
@@ -795,6 +853,29 @@ end
|
|||||||
--- Update momentum scrolling (call every frame with dt)
|
--- Update momentum scrolling (call every frame with dt)
|
||||||
---@param dt number Delta time in seconds
|
---@param dt number Delta time in seconds
|
||||||
function ScrollManager:update(dt)
|
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
|
if not self._momentumScrolling then
|
||||||
-- Handle bounce back if overscrolled
|
-- Handle bounce back if overscrolled
|
||||||
if self.bounceEnabled then
|
if self.bounceEnabled then
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ local function validateThemeDefinition(definition)
|
|||||||
return false, "Theme 'fonts' must be a table"
|
return false, "Theme 'fonts' must be a table"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if definition.scrollbars and type(definition.scrollbars) ~= "table" then
|
||||||
|
return false, "Theme 'scrollbars' must be a table"
|
||||||
|
end
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -331,6 +335,7 @@ end
|
|||||||
---@field name string
|
---@field name string
|
||||||
---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component)
|
---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component)
|
||||||
---@field components table<string, ThemeComponent>
|
---@field components table<string, ThemeComponent>
|
||||||
|
---@field scrollbars table<string, ThemeComponent>? -- Optional: scrollbar component definitions (uses ThemeComponent format)
|
||||||
---@field colors table<string, Color>?
|
---@field colors table<string, Color>?
|
||||||
---@field fonts table<string, string>? -- Optional: font family definitions (name -> path)
|
---@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
|
---@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 atlas love.Image? -- Optional: global atlas
|
||||||
---@field atlasData love.ImageData?
|
---@field atlasData love.ImageData?
|
||||||
---@field components table<string, ThemeComponent>
|
---@field components table<string, ThemeComponent>
|
||||||
|
---@field scrollbars table<string, ThemeComponent> -- Scrollbar component definitions
|
||||||
---@field colors table<string, Color>
|
---@field colors table<string, Color>
|
||||||
---@field fonts table<string, string> -- Font family definitions
|
---@field fonts table<string, string> -- Font family definitions
|
||||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions
|
||||||
@@ -409,6 +415,7 @@ function Theme.new(definition)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.components = definition.components or {}
|
self.components = definition.components or {}
|
||||||
|
self.scrollbars = definition.scrollbars or {}
|
||||||
self.colors = definition.colors or {}
|
self.colors = definition.colors or {}
|
||||||
self.fonts = definition.fonts or {}
|
self.fonts = definition.fonts or {}
|
||||||
self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil
|
self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil
|
||||||
@@ -552,6 +559,84 @@ function Theme.new(definition)
|
|||||||
end
|
end
|
||||||
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
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -646,6 +731,50 @@ function Theme.getComponent(componentName, state)
|
|||||||
return component
|
return component
|
||||||
end
|
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
|
--- Access theme-defined fonts for consistent typography across your UI
|
||||||
--- Use this to load fonts specified in your theme definition
|
--- Use this to load fonts specified in your theme definition
|
||||||
---@param fontName string Name of the font family (e.g., "default", "heading")
|
---@param fontName string Name of the font family (e.g., "default", "heading")
|
||||||
@@ -889,6 +1018,26 @@ function ThemeManager:getStateComponent()
|
|||||||
return component
|
return component
|
||||||
end
|
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
|
---Get a style property from the current state component
|
||||||
---@param property string The property name
|
---@param property string The property name
|
||||||
---@return any? value The property value, or nil if not found
|
---@return any? value The property value, or nil if not found
|
||||||
@@ -1219,6 +1368,69 @@ function Theme.validateTheme(theme, options)
|
|||||||
end
|
end
|
||||||
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)
|
-- contentAutoSizingMultiplier validation (optional)
|
||||||
if theme.contentAutoSizingMultiplier ~= nil then
|
if theme.contentAutoSizingMultiplier ~= nil then
|
||||||
if type(theme.contentAutoSizingMultiplier) ~= "table" then
|
if type(theme.contentAutoSizingMultiplier) ~= "table" then
|
||||||
@@ -1254,6 +1466,7 @@ function Theme.validateTheme(theme, options)
|
|||||||
name = true,
|
name = true,
|
||||||
atlas = true,
|
atlas = true,
|
||||||
components = true,
|
components = true,
|
||||||
|
scrollbars = true,
|
||||||
colors = true,
|
colors = true,
|
||||||
fonts = true,
|
fonts = true,
|
||||||
contentAutoSizingMultiplier = true,
|
contentAutoSizingMultiplier = true,
|
||||||
@@ -1330,6 +1543,11 @@ function Theme.sanitizeTheme(theme)
|
|||||||
sanitized.components = theme.components
|
sanitized.components = theme.components
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Sanitize scrollbars (preserve as-is, they're complex like components)
|
||||||
|
if type(theme.scrollbars) == "table" then
|
||||||
|
sanitized.scrollbars = theme.scrollbars
|
||||||
|
end
|
||||||
|
|
||||||
-- Sanitize contentAutoSizingMultiplier
|
-- Sanitize contentAutoSizingMultiplier
|
||||||
if type(theme.contentAutoSizingMultiplier) == "table" then
|
if type(theme.contentAutoSizingMultiplier) == "table" then
|
||||||
sanitized.contentAutoSizingMultiplier = {}
|
sanitized.contentAutoSizingMultiplier = {}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ 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 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 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
|
||||||
|
|||||||
@@ -1302,6 +1302,166 @@ function TestFlexLoveUnhappyPaths:testImmediateModeFrameEdgeCases()
|
|||||||
luaunit.assertTrue(true)
|
luaunit.assertTrue(true)
|
||||||
end
|
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
|
if not _G.RUNNING_ALL_TESTS then
|
||||||
os.exit(luaunit.LuaUnit.run())
|
os.exit(luaunit.LuaUnit.run())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ local Color = require("libs.FlexLove").Color
|
|||||||
return {
|
return {
|
||||||
name = "Metal Theme",
|
name = "Metal Theme",
|
||||||
contentAutoSizingMultiplier = { width = 1.05, height = 1.1 },
|
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 = {
|
components = {
|
||||||
framev1 = {
|
framev1 = {
|
||||||
atlas = "themes/metal/Frame/Frame01a.9.png",
|
atlas = "themes/metal/Frame/Frame01a.9.png",
|
||||||
|
|||||||
Reference in New Issue
Block a user