778 lines
29 KiB
Lua
778 lines
29 KiB
Lua
--[[
|
|
ScrollManager.lua - Scrolling and overflow management for FlexLove
|
|
Handles overflow detection, scrollbar rendering, and scrollbar interaction
|
|
Extracted from Element.lua for better modularity and testability
|
|
]]
|
|
|
|
-- ====================
|
|
-- Module Setup
|
|
-- ====================
|
|
|
|
-- Setup module path for relative requires
|
|
local modulePath = (...):match("(.-)[^%.]+$")
|
|
local function req(name)
|
|
return require(modulePath .. name)
|
|
end
|
|
|
|
-- Module dependencies
|
|
local Color = req("Color")
|
|
|
|
-- ====================
|
|
-- Error Handling Utilities
|
|
-- ====================
|
|
|
|
--- Standardized error message formatter
|
|
---@param module string -- Module name
|
|
---@param message string -- Error message
|
|
---@return string -- Formatted error message
|
|
local function formatError(module, message)
|
|
return string.format("[FlexLove.%s] %s", module, message)
|
|
end
|
|
|
|
-- ====================
|
|
-- ScrollManager Class
|
|
-- ====================
|
|
|
|
---@class ScrollManager
|
|
---@field overflow string -- Overflow mode for both axes ("visible"|"hidden"|"scroll"|"auto")
|
|
---@field overflowX string? -- Overflow mode for X axis (overrides overflow)
|
|
---@field overflowY string? -- Overflow mode for Y axis (overrides overflow)
|
|
---@field scrollbarWidth number -- Width of scrollbar track/thumb
|
|
---@field scrollbarColor Color -- Color of scrollbar thumb
|
|
---@field scrollbarTrackColor Color -- Color of scrollbar track
|
|
---@field scrollbarRadius number -- Corner radius of scrollbar
|
|
---@field scrollbarPadding number -- Padding around scrollbar from container edge
|
|
---@field scrollSpeed number -- Scroll speed multiplier for wheel events
|
|
---@field hideScrollbars {vertical:boolean, horizontal:boolean} -- Hide scrollbars
|
|
---@field _element Element? -- Reference to parent element
|
|
---@field _overflowX boolean -- Whether content overflows horizontally
|
|
---@field _overflowY boolean -- Whether content overflows vertically
|
|
---@field _contentWidth number -- Total content width (including overflow)
|
|
---@field _contentHeight number -- Total content height (including overflow)
|
|
---@field _scrollX number -- Current horizontal scroll position
|
|
---@field _scrollY number -- Current vertical scroll position
|
|
---@field _maxScrollX number -- Maximum horizontal scroll position
|
|
---@field _maxScrollY number -- Maximum vertical scroll position
|
|
---@field _scrollbarHoveredVertical boolean -- Whether vertical scrollbar is hovered
|
|
---@field _scrollbarHoveredHorizontal boolean -- Whether horizontal scrollbar is hovered
|
|
---@field _scrollbarDragging boolean -- Whether a scrollbar is being dragged
|
|
---@field _hoveredScrollbar string? -- Which scrollbar is hovered ("vertical"|"horizontal")
|
|
---@field _scrollbarDragOffset number -- Offset from thumb top when drag started
|
|
---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled
|
|
local ScrollManager = {}
|
|
ScrollManager.__index = ScrollManager
|
|
|
|
--- Create a new ScrollManager instance
|
|
---@param config table -- Configuration options
|
|
---@return ScrollManager
|
|
function ScrollManager.new(config)
|
|
if not config then
|
|
error(formatError("ScrollManager", "Configuration table is required"))
|
|
end
|
|
|
|
local self = setmetatable({}, ScrollManager)
|
|
|
|
-- Overflow configuration
|
|
self.overflow = config.overflow or "hidden"
|
|
self.overflowX = config.overflowX
|
|
self.overflowY = config.overflowY
|
|
|
|
-- Scrollbar appearance
|
|
self.scrollbarWidth = config.scrollbarWidth or 12
|
|
self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8)
|
|
self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5)
|
|
self.scrollbarRadius = config.scrollbarRadius or 6
|
|
self.scrollbarPadding = config.scrollbarPadding or 2
|
|
self.scrollSpeed = config.scrollSpeed or 20
|
|
|
|
-- Validate Color objects
|
|
if type(self.scrollbarColor) ~= "table" or not self.scrollbarColor.toRGBA then
|
|
error(formatError("ScrollManager", "scrollbarColor must be a Color object"))
|
|
end
|
|
if type(self.scrollbarTrackColor) ~= "table" or not self.scrollbarTrackColor.toRGBA then
|
|
error(formatError("ScrollManager", "scrollbarTrackColor must be a Color object"))
|
|
end
|
|
|
|
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
|
|
if config.hideScrollbars ~= nil then
|
|
if type(config.hideScrollbars) == "boolean" then
|
|
self.hideScrollbars = { vertical = config.hideScrollbars, horizontal = config.hideScrollbars }
|
|
elseif type(config.hideScrollbars) == "table" then
|
|
self.hideScrollbars = {
|
|
vertical = config.hideScrollbars.vertical ~= nil and config.hideScrollbars.vertical or false,
|
|
horizontal = config.hideScrollbars.horizontal ~= nil and config.hideScrollbars.horizontal or false,
|
|
}
|
|
else
|
|
self.hideScrollbars = { vertical = false, horizontal = false }
|
|
end
|
|
else
|
|
self.hideScrollbars = { vertical = false, horizontal = false }
|
|
end
|
|
|
|
-- Internal state
|
|
self._element = nil
|
|
self._overflowX = false
|
|
self._overflowY = false
|
|
self._contentWidth = 0
|
|
self._contentHeight = 0
|
|
self._scrollX = config._scrollX or 0
|
|
self._scrollY = config._scrollY or 0
|
|
self._maxScrollX = 0
|
|
self._maxScrollY = 0
|
|
|
|
-- Scrollbar interaction state
|
|
self._scrollbarHoveredVertical = false
|
|
self._scrollbarHoveredHorizontal = false
|
|
self._scrollbarDragging = false
|
|
self._hoveredScrollbar = nil
|
|
self._scrollbarDragOffset = 0
|
|
self._scrollbarPressHandled = false
|
|
|
|
return self
|
|
end
|
|
|
|
--- Initialize ScrollManager with parent element reference
|
|
---@param element Element -- Parent element
|
|
function ScrollManager:initialize(element)
|
|
if not element then
|
|
error(formatError("ScrollManager", "Element reference is required"))
|
|
end
|
|
self._element = element
|
|
end
|
|
|
|
--- Detect if content overflows container bounds
|
|
--- Calculates content dimensions and overflow state based on children
|
|
function ScrollManager:detectOverflow()
|
|
if not self._element then
|
|
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
|
|
end
|
|
|
|
-- Reset overflow state
|
|
self._overflowX = false
|
|
self._overflowY = false
|
|
self._contentWidth = self._element.width
|
|
self._contentHeight = self._element.height
|
|
|
|
-- Skip detection if overflow is visible (no clipping needed)
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
if overflowX == "visible" and overflowY == "visible" then
|
|
return
|
|
end
|
|
|
|
-- Calculate content bounds based on children
|
|
if #self._element.children == 0 then
|
|
return -- No children, no overflow
|
|
end
|
|
|
|
local minX, minY = 0, 0
|
|
local maxX, maxY = 0, 0
|
|
|
|
-- Content area starts after padding
|
|
local contentX = self._element.x + self._element.padding.left
|
|
local contentY = self._element.y + self._element.padding.top
|
|
|
|
for _, child in ipairs(self._element.children) do
|
|
-- Skip absolutely positioned children (they don't contribute to overflow)
|
|
if not child._explicitlyAbsolute then
|
|
-- Calculate child position relative to content area
|
|
local childLeft = child.x - contentX
|
|
local childTop = child.y - contentY
|
|
local childRight = childLeft + child:getBorderBoxWidth() + child.margin.right
|
|
local childBottom = childTop + child:getBorderBoxHeight() + child.margin.bottom
|
|
|
|
maxX = math.max(maxX, childRight)
|
|
maxY = math.max(maxY, childBottom)
|
|
end
|
|
end
|
|
|
|
-- Calculate content dimensions
|
|
self._contentWidth = maxX
|
|
self._contentHeight = maxY
|
|
|
|
-- Detect overflow
|
|
local containerWidth = self._element.width
|
|
local containerHeight = self._element.height
|
|
|
|
self._overflowX = self._contentWidth > containerWidth
|
|
self._overflowY = self._contentHeight > containerHeight
|
|
|
|
-- Calculate maximum scroll bounds
|
|
self._maxScrollX = math.max(0, self._contentWidth - containerWidth)
|
|
self._maxScrollY = math.max(0, self._contentHeight - containerHeight)
|
|
|
|
-- Clamp current scroll position to new bounds
|
|
self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX))
|
|
self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY))
|
|
end
|
|
|
|
--- Set scroll position with bounds clamping
|
|
---@param x number? -- X scroll position (nil to keep current)
|
|
---@param y number? -- Y scroll position (nil to keep current)
|
|
function ScrollManager:setScroll(x, y)
|
|
if x ~= nil then
|
|
if type(x) ~= "number" then
|
|
error(formatError("ScrollManager", "Scroll X position must be a number"))
|
|
end
|
|
self._scrollX = math.max(0, math.min(x, self._maxScrollX))
|
|
end
|
|
if y ~= nil then
|
|
if type(y) ~= "number" then
|
|
error(formatError("ScrollManager", "Scroll Y position must be a number"))
|
|
end
|
|
self._scrollY = math.max(0, math.min(y, self._maxScrollY))
|
|
end
|
|
end
|
|
|
|
--- Get current scroll position
|
|
---@return number scrollX, number scrollY
|
|
function ScrollManager:getScroll()
|
|
return self._scrollX, self._scrollY
|
|
end
|
|
|
|
--- Scroll by delta amount (relative scrolling)
|
|
---@param dx number? -- X delta (nil for no change)
|
|
---@param dy number? -- Y delta (nil for no change)
|
|
function ScrollManager:scroll(dx, dy)
|
|
if dx ~= nil then
|
|
if type(dx) ~= "number" then
|
|
error(formatError("ScrollManager", "Scroll delta X must be a number"))
|
|
end
|
|
self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX))
|
|
end
|
|
if dy ~= nil then
|
|
if type(dy) ~= "number" then
|
|
error(formatError("ScrollManager", "Scroll delta Y must be a number"))
|
|
end
|
|
self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY))
|
|
end
|
|
end
|
|
|
|
--- Calculate scrollbar dimensions and positions
|
|
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
|
|
function ScrollManager:calculateScrollbarDimensions()
|
|
if not self._element then
|
|
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
|
|
end
|
|
|
|
local result = {
|
|
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
|
|
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
|
|
}
|
|
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
|
|
-- Vertical scrollbar
|
|
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
|
|
if overflowY == "scroll" then
|
|
-- Always show scrollbar for "scroll" mode
|
|
result.vertical.visible = true
|
|
result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2)
|
|
|
|
if self._overflowY then
|
|
-- Content overflows, calculate proper thumb size
|
|
local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height)
|
|
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
|
|
|
|
-- Calculate thumb position based on scroll ratio
|
|
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
|
|
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
|
|
result.vertical.thumbY = maxThumbY * scrollRatio
|
|
else
|
|
-- No overflow, thumb fills entire track
|
|
result.vertical.thumbHeight = result.vertical.trackHeight
|
|
result.vertical.thumbY = 0
|
|
end
|
|
elseif self._overflowY and overflowY == "auto" then
|
|
-- Only show scrollbar when content actually overflows
|
|
result.vertical.visible = true
|
|
result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2)
|
|
|
|
-- Calculate thumb height based on content ratio
|
|
local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height)
|
|
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
|
|
|
|
-- Calculate thumb position based on scroll ratio
|
|
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
|
|
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
|
|
result.vertical.thumbY = maxThumbY * scrollRatio
|
|
end
|
|
|
|
-- Horizontal scrollbar
|
|
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
|
|
if overflowX == "scroll" then
|
|
-- Always show scrollbar for "scroll" mode
|
|
result.horizontal.visible = true
|
|
result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2)
|
|
|
|
if self._overflowX then
|
|
-- Content overflows, calculate proper thumb size
|
|
local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width)
|
|
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
|
|
|
|
-- Calculate thumb position based on scroll ratio
|
|
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
|
|
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
|
|
result.horizontal.thumbX = maxThumbX * scrollRatio
|
|
else
|
|
-- No overflow, thumb fills entire track
|
|
result.horizontal.thumbWidth = result.horizontal.trackWidth
|
|
result.horizontal.thumbX = 0
|
|
end
|
|
elseif self._overflowX and overflowX == "auto" then
|
|
-- Only show scrollbar when content actually overflows
|
|
result.horizontal.visible = true
|
|
result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2)
|
|
|
|
-- Calculate thumb width based on content ratio
|
|
local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width)
|
|
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
|
|
|
|
-- Calculate thumb position based on scroll ratio
|
|
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
|
|
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
|
|
result.horizontal.thumbX = maxThumbX * scrollRatio
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
--- Draw scrollbars
|
|
---@param x number -- Element X position
|
|
---@param y number -- Element Y position
|
|
---@param width number -- Element width
|
|
---@param height number -- Element height
|
|
function ScrollManager:drawScrollbars(x, y, width, height)
|
|
if not self._element then
|
|
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
|
|
end
|
|
|
|
local dims = self:calculateScrollbarDimensions()
|
|
|
|
-- Vertical scrollbar
|
|
if dims.vertical.visible and not self.hideScrollbars.vertical then
|
|
-- Position scrollbar within content area (x, y is border-box origin)
|
|
local contentX = x + self._element.padding.left
|
|
local contentY = y + self._element.padding.top
|
|
local trackX = contentX + width - self.scrollbarWidth - self.scrollbarPadding
|
|
local trackY = contentY + self.scrollbarPadding
|
|
|
|
-- Determine thumb color based on state (independent for vertical)
|
|
local thumbColor = self.scrollbarColor
|
|
if self._scrollbarDragging and self._hoveredScrollbar == "vertical" then
|
|
-- Active state: brighter
|
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
|
elseif self._scrollbarHoveredVertical then
|
|
-- Hover state: slightly brighter
|
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
|
end
|
|
|
|
-- Draw track
|
|
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
|
|
love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius)
|
|
|
|
-- Draw thumb with state-based color
|
|
love.graphics.setColor(thumbColor:toRGBA())
|
|
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, self.scrollbarWidth, dims.vertical.thumbHeight, self.scrollbarRadius)
|
|
end
|
|
|
|
-- Horizontal scrollbar
|
|
if dims.horizontal.visible and not self.hideScrollbars.horizontal then
|
|
-- Position scrollbar within content area (x, y is border-box origin)
|
|
local contentX = x + self._element.padding.left
|
|
local contentY = y + self._element.padding.top
|
|
local trackX = contentX + self.scrollbarPadding
|
|
local trackY = contentY + height - self.scrollbarWidth - self.scrollbarPadding
|
|
|
|
-- Determine thumb color based on state (independent for horizontal)
|
|
local thumbColor = self.scrollbarColor
|
|
if self._scrollbarDragging and self._hoveredScrollbar == "horizontal" then
|
|
-- Active state: brighter
|
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
|
elseif self._scrollbarHoveredHorizontal then
|
|
-- Hover state: slightly brighter
|
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
|
end
|
|
|
|
-- Draw track
|
|
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
|
|
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius)
|
|
|
|
-- Draw thumb with state-based color
|
|
love.graphics.setColor(thumbColor:toRGBA())
|
|
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, self.scrollbarWidth, self.scrollbarRadius)
|
|
end
|
|
|
|
-- Reset color
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
--- Get scrollbar at mouse position
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
|
|
function ScrollManager:_getScrollbarAtPosition(mouseX, mouseY)
|
|
if not self._element then
|
|
return nil
|
|
end
|
|
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
|
|
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
|
|
return nil
|
|
end
|
|
|
|
local dims = self:calculateScrollbarDimensions()
|
|
local x, y = self._element.x, self._element.y
|
|
local w, h = self._element.width, self._element.height
|
|
|
|
-- Check vertical scrollbar (only if not hidden)
|
|
if dims.vertical.visible and not self.hideScrollbars.vertical then
|
|
-- Position scrollbar within content area (x, y is border-box origin)
|
|
local contentX = x + self._element.padding.left
|
|
local contentY = y + self._element.padding.top
|
|
local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding
|
|
local trackY = contentY + self.scrollbarPadding
|
|
local trackW = self.scrollbarWidth
|
|
local trackH = dims.vertical.trackHeight
|
|
|
|
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
|
|
-- Check if over thumb
|
|
local thumbY = trackY + dims.vertical.thumbY
|
|
local thumbH = dims.vertical.thumbHeight
|
|
if mouseY >= thumbY and mouseY <= thumbY + thumbH then
|
|
return { component = "vertical", region = "thumb" }
|
|
else
|
|
return { component = "vertical", region = "track" }
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check horizontal scrollbar (only if not hidden)
|
|
if dims.horizontal.visible and not self.hideScrollbars.horizontal then
|
|
-- Position scrollbar within content area (x, y is border-box origin)
|
|
local contentX = x + self._element.padding.left
|
|
local contentY = y + self._element.padding.top
|
|
local trackX = contentX + self.scrollbarPadding
|
|
local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding
|
|
local trackW = dims.horizontal.trackWidth
|
|
local trackH = self.scrollbarWidth
|
|
|
|
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
|
|
-- Check if over thumb
|
|
local thumbX = trackX + dims.horizontal.thumbX
|
|
local thumbW = dims.horizontal.thumbWidth
|
|
if mouseX >= thumbX and mouseX <= thumbX + thumbW then
|
|
return { component = "horizontal", region = "thumb" }
|
|
else
|
|
return { component = "horizontal", region = "track" }
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
--- Handle scrollbar mouse press
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@param button number
|
|
---@return boolean -- True if event was consumed
|
|
function ScrollManager:handleMousePress(mouseX, mouseY, button)
|
|
if button ~= 1 then
|
|
return false
|
|
end -- Only left click
|
|
|
|
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
|
|
if not scrollbar then
|
|
return false
|
|
end
|
|
|
|
if scrollbar.region == "thumb" then
|
|
-- Start dragging thumb
|
|
self._scrollbarDragging = true
|
|
self._hoveredScrollbar = scrollbar.component
|
|
local dims = self:calculateScrollbarDimensions()
|
|
|
|
if scrollbar.component == "vertical" then
|
|
local contentY = self._element.y + self._element.padding.top
|
|
local trackY = contentY + self.scrollbarPadding
|
|
local thumbY = trackY + dims.vertical.thumbY
|
|
self._scrollbarDragOffset = mouseY - thumbY
|
|
elseif scrollbar.component == "horizontal" then
|
|
local contentX = self._element.x + self._element.padding.left
|
|
local trackX = contentX + self.scrollbarPadding
|
|
local thumbX = trackX + dims.horizontal.thumbX
|
|
self._scrollbarDragOffset = mouseX - thumbX
|
|
end
|
|
|
|
return true -- Event consumed
|
|
elseif scrollbar.region == "track" then
|
|
-- Click on track - jump to position
|
|
self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component)
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Handle scrollbar release
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@param button number
|
|
---@return boolean -- True if event was consumed
|
|
function ScrollManager:handleMouseRelease(mouseX, mouseY, button)
|
|
if button ~= 1 then
|
|
return false
|
|
end
|
|
|
|
if self._scrollbarDragging then
|
|
self._scrollbarDragging = false
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Handle scrollbar drag
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@return boolean -- True if event was consumed
|
|
function ScrollManager:handleMouseMove(mouseX, mouseY)
|
|
if not self._scrollbarDragging then
|
|
return false
|
|
end
|
|
|
|
local dims = self:calculateScrollbarDimensions()
|
|
|
|
if self._hoveredScrollbar == "vertical" then
|
|
local contentY = self._element.y + self._element.padding.top
|
|
local trackY = contentY + self.scrollbarPadding
|
|
local trackH = dims.vertical.trackHeight
|
|
local thumbH = dims.vertical.thumbHeight
|
|
|
|
-- Calculate new thumb position
|
|
local newThumbY = mouseY - self._scrollbarDragOffset - trackY
|
|
newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH))
|
|
|
|
-- Convert thumb position to scroll position
|
|
local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0
|
|
local newScrollY = scrollRatio * self._maxScrollY
|
|
|
|
self:setScroll(nil, newScrollY)
|
|
return true
|
|
elseif self._hoveredScrollbar == "horizontal" then
|
|
local contentX = self._element.x + self._element.padding.left
|
|
local trackX = contentX + self.scrollbarPadding
|
|
local trackW = dims.horizontal.trackWidth
|
|
local thumbW = dims.horizontal.thumbWidth
|
|
|
|
-- Calculate new thumb position
|
|
local newThumbX = mouseX - self._scrollbarDragOffset - trackX
|
|
newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW))
|
|
|
|
-- Convert thumb position to scroll position
|
|
local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0
|
|
local newScrollX = scrollRatio * self._maxScrollX
|
|
|
|
self:setScroll(newScrollX, nil)
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Handle mouse wheel scrolling
|
|
---@param x number -- Horizontal scroll amount
|
|
---@param y number -- Vertical scroll amount
|
|
---@return boolean -- True if scroll was handled
|
|
function ScrollManager:handleWheel(x, y)
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
|
|
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
|
|
return false
|
|
end
|
|
|
|
local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0
|
|
local hasHorizontalOverflow = self._overflowX 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
|
|
local newScrollY = self._scrollY + delta
|
|
self:setScroll(nil, newScrollY)
|
|
scrolled = true
|
|
end
|
|
|
|
-- Horizontal scrolling
|
|
if x ~= 0 and hasHorizontalOverflow then
|
|
local delta = -x * self.scrollSpeed
|
|
local newScrollX = self._scrollX + delta
|
|
self:setScroll(newScrollX, nil)
|
|
scrolled = true
|
|
end
|
|
|
|
return scrolled
|
|
end
|
|
|
|
--- Check if scrollbar is hovered at position
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@return boolean vertical, boolean horizontal
|
|
function ScrollManager:isScrollbarHovered(mouseX, mouseY)
|
|
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
|
|
if not scrollbar then
|
|
return false, false
|
|
end
|
|
return scrollbar.component == "vertical", scrollbar.component == "horizontal"
|
|
end
|
|
|
|
--- Get content bounds and scroll limits
|
|
---@return number contentWidth, number contentHeight, number maxScrollX, number maxScrollY
|
|
function ScrollManager:getContentBounds()
|
|
return self._contentWidth, self._contentHeight, self._maxScrollX, self._maxScrollY
|
|
end
|
|
|
|
--- Update scrollbar state (called each frame)
|
|
---@param dt number -- Delta time
|
|
---@param mouseX number -- Current mouse X position
|
|
---@param mouseY number -- Current mouse Y position
|
|
function ScrollManager:update(dt, mouseX, mouseY)
|
|
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
|
|
|
|
-- Update independent hover states for vertical and horizontal scrollbars
|
|
if scrollbar and scrollbar.component == "vertical" then
|
|
self._scrollbarHoveredVertical = true
|
|
self._hoveredScrollbar = "vertical"
|
|
else
|
|
if not (self._scrollbarDragging and self._hoveredScrollbar == "vertical") then
|
|
self._scrollbarHoveredVertical = false
|
|
end
|
|
end
|
|
|
|
if scrollbar and scrollbar.component == "horizontal" then
|
|
self._scrollbarHoveredHorizontal = true
|
|
self._hoveredScrollbar = "horizontal"
|
|
else
|
|
if not (self._scrollbarDragging and self._hoveredScrollbar == "horizontal") then
|
|
self._scrollbarHoveredHorizontal = false
|
|
end
|
|
end
|
|
|
|
-- Clear hoveredScrollbar if neither is hovered
|
|
if not scrollbar and not self._scrollbarDragging then
|
|
self._hoveredScrollbar = nil
|
|
end
|
|
end
|
|
|
|
--- Scroll to track click position (jump to position)
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@param component string -- "vertical" or "horizontal"
|
|
function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component)
|
|
local dims = self:calculateScrollbarDimensions()
|
|
|
|
if component == "vertical" then
|
|
local contentY = self._element.y + self._element.padding.top
|
|
local trackY = contentY + self.scrollbarPadding
|
|
local trackH = dims.vertical.trackHeight
|
|
local thumbH = dims.vertical.thumbHeight
|
|
|
|
-- Calculate target thumb position (centered on click)
|
|
local targetThumbY = mouseY - trackY - (thumbH / 2)
|
|
targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH))
|
|
|
|
-- Convert to scroll position
|
|
local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0
|
|
local newScrollY = scrollRatio * self._maxScrollY
|
|
|
|
self:setScroll(nil, newScrollY)
|
|
elseif component == "horizontal" then
|
|
local contentX = self._element.x + self._element.padding.left
|
|
local trackX = contentX + self.scrollbarPadding
|
|
local trackW = dims.horizontal.trackWidth
|
|
local thumbW = dims.horizontal.thumbWidth
|
|
|
|
-- Calculate target thumb position (centered on click)
|
|
local targetThumbX = mouseX - trackX - (thumbW / 2)
|
|
targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW))
|
|
|
|
-- Convert to scroll position
|
|
local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0
|
|
local newScrollX = scrollRatio * self._maxScrollX
|
|
|
|
self:setScroll(newScrollX, nil)
|
|
end
|
|
end
|
|
|
|
--- Get current scrollbar dragging state
|
|
---@return boolean dragging, string? component
|
|
function ScrollManager:getDraggingState()
|
|
return self._scrollbarDragging, self._hoveredScrollbar
|
|
end
|
|
|
|
--- Set scrollbar dragging state (for state restoration)
|
|
---@param dragging boolean
|
|
---@param component string? -- "vertical" or "horizontal"
|
|
---@param dragOffset number?
|
|
function ScrollManager:setDraggingState(dragging, component, dragOffset)
|
|
self._scrollbarDragging = dragging
|
|
self._hoveredScrollbar = component
|
|
self._scrollbarDragOffset = dragOffset or 0
|
|
end
|
|
|
|
--- Get scrollbar hover state
|
|
---@return boolean vertical, boolean horizontal
|
|
function ScrollManager:getHoverState()
|
|
return self._scrollbarHoveredVertical, self._scrollbarHoveredHorizontal
|
|
end
|
|
|
|
--- Set scrollbar hover state (for state restoration)
|
|
---@param vertical boolean
|
|
---@param horizontal boolean
|
|
function ScrollManager:setHoverState(vertical, horizontal)
|
|
self._scrollbarHoveredVertical = vertical
|
|
self._scrollbarHoveredHorizontal = horizontal
|
|
end
|
|
|
|
--- Check if element has overflow
|
|
---@return boolean hasOverflowX, boolean hasOverflowY
|
|
function ScrollManager:hasOverflow()
|
|
return self._overflowX, self._overflowY
|
|
end
|
|
|
|
--- Get scroll percentage (0-1)
|
|
---@return number percentX, number percentY
|
|
function ScrollManager:getScrollPercentage()
|
|
local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
|
|
local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
|
|
return percentX, percentY
|
|
end
|
|
|
|
--- Scroll to top
|
|
function ScrollManager:scrollToTop()
|
|
self:setScroll(nil, 0)
|
|
end
|
|
|
|
--- Scroll to bottom
|
|
function ScrollManager:scrollToBottom()
|
|
self:setScroll(nil, self._maxScrollY)
|
|
end
|
|
|
|
--- Scroll to left
|
|
function ScrollManager:scrollToLeft()
|
|
self:setScroll(0, nil)
|
|
end
|
|
|
|
--- Scroll to right
|
|
function ScrollManager:scrollToRight()
|
|
self:setScroll(self._maxScrollX, nil)
|
|
end
|
|
|
|
return ScrollManager
|