cleanup
This commit is contained in:
@@ -1,10 +1,3 @@
|
||||
--- ScrollManager.lua
|
||||
--- Handles scrolling, overflow detection, and scrollbar rendering/interaction for Elements
|
||||
--- Extracted from Element.lua as part of element-refactor-modularization task 05
|
||||
---
|
||||
--- Dependencies (must be injected via deps parameter):
|
||||
--- - Color: Color module for creating color instances
|
||||
|
||||
---@class ScrollManager
|
||||
---@field overflow string -- "visible"|"hidden"|"auto"|"scroll"
|
||||
---@field overflowX string? -- X-axis specific overflow (overrides overflow)
|
||||
@@ -41,15 +34,15 @@ ScrollManager.__index = ScrollManager
|
||||
function ScrollManager.new(config, deps)
|
||||
local Color = deps.Color
|
||||
local self = setmetatable({}, ScrollManager)
|
||||
|
||||
|
||||
-- Store dependency for instance methods
|
||||
self._Color = Color
|
||||
|
||||
|
||||
-- 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)
|
||||
@@ -57,7 +50,7 @@ function ScrollManager.new(config, deps)
|
||||
self.scrollbarRadius = config.scrollbarRadius or 6
|
||||
self.scrollbarPadding = config.scrollbarPadding or 2
|
||||
self.scrollSpeed = config.scrollSpeed or 20
|
||||
|
||||
|
||||
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
|
||||
if config.hideScrollbars ~= nil then
|
||||
if type(config.hideScrollbars) == "boolean" then
|
||||
@@ -73,19 +66,19 @@ function ScrollManager.new(config, deps)
|
||||
else
|
||||
self.hideScrollbars = { vertical = false, horizontal = false }
|
||||
end
|
||||
|
||||
|
||||
-- Internal overflow state
|
||||
self._overflowX = false
|
||||
self._overflowY = false
|
||||
self._contentWidth = 0
|
||||
self._contentHeight = 0
|
||||
|
||||
|
||||
-- Scroll state (can be restored from config in immediate mode)
|
||||
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
|
||||
@@ -93,10 +86,10 @@ function ScrollManager.new(config, deps)
|
||||
self._hoveredScrollbar = nil -- "vertical" or "horizontal"
|
||||
self._scrollbarDragOffset = 0
|
||||
self._scrollbarPressHandled = false
|
||||
|
||||
|
||||
-- Element reference (set via initialize)
|
||||
self._element = nil
|
||||
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -111,34 +104,34 @@ function ScrollManager:detectOverflow()
|
||||
if not self._element then
|
||||
error("ScrollManager:detectOverflow() called before initialize()")
|
||||
end
|
||||
|
||||
|
||||
local element = self._element
|
||||
|
||||
|
||||
-- Reset overflow state
|
||||
self._overflowX = false
|
||||
self._overflowY = false
|
||||
self._contentWidth = element.width
|
||||
self._contentHeight = 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 #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 = element.x + element.padding.left
|
||||
local contentY = element.y + element.padding.top
|
||||
|
||||
|
||||
for _, child in ipairs(element.children) do
|
||||
-- Skip absolutely positioned children (they don't contribute to overflow)
|
||||
if not child._explicitlyAbsolute then
|
||||
@@ -147,27 +140,27 @@ function ScrollManager:detectOverflow()
|
||||
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 = element.width
|
||||
local containerHeight = 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))
|
||||
@@ -235,28 +228,28 @@ function ScrollManager:calculateScrollbarDimensions()
|
||||
if not self._element then
|
||||
error("ScrollManager:calculateScrollbarDimensions() called before initialize()")
|
||||
end
|
||||
|
||||
|
||||
local element = self._element
|
||||
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 = element.height - (self.scrollbarPadding * 2)
|
||||
|
||||
|
||||
if self._overflowY then
|
||||
-- Content overflows, calculate proper thumb size
|
||||
local contentRatio = element.height / math.max(self._contentHeight, 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
|
||||
@@ -270,29 +263,29 @@ function ScrollManager:calculateScrollbarDimensions()
|
||||
-- Only show scrollbar when content actually overflows
|
||||
result.vertical.visible = true
|
||||
result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2)
|
||||
|
||||
|
||||
-- Calculate thumb height based on content ratio
|
||||
local contentRatio = element.height / math.max(self._contentHeight, 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 = element.width - (self.scrollbarPadding * 2)
|
||||
|
||||
|
||||
if self._overflowX then
|
||||
-- Content overflows, calculate proper thumb size
|
||||
local contentRatio = element.width / math.max(self._contentWidth, 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
|
||||
@@ -306,17 +299,17 @@ function ScrollManager:calculateScrollbarDimensions()
|
||||
-- Only show scrollbar when content actually overflows
|
||||
result.horizontal.visible = true
|
||||
result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2)
|
||||
|
||||
|
||||
-- Calculate thumb width based on content ratio
|
||||
local contentRatio = element.width / math.max(self._contentWidth, 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
|
||||
|
||||
@@ -328,19 +321,19 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
|
||||
if not self._element then
|
||||
error("ScrollManager:getScrollbarAtPosition() called before initialize()")
|
||||
end
|
||||
|
||||
|
||||
local element = self._element
|
||||
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 = element.x, element.y
|
||||
local w, h = element.width, 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)
|
||||
@@ -350,7 +343,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
|
||||
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
|
||||
@@ -362,7 +355,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
|
||||
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)
|
||||
@@ -372,7 +365,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
|
||||
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
|
||||
@@ -384,7 +377,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -397,23 +390,23 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
|
||||
if not self._element then
|
||||
error("ScrollManager:handleMousePress() called before initialize()")
|
||||
end
|
||||
|
||||
|
||||
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()
|
||||
local element = self._element
|
||||
|
||||
|
||||
if scrollbar.component == "vertical" then
|
||||
local contentY = element.y + element.padding.top
|
||||
local trackY = contentY + self.scrollbarPadding
|
||||
@@ -425,14 +418,14 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
|
||||
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
|
||||
|
||||
@@ -444,28 +437,28 @@ function ScrollManager:handleMouseMove(mouseX, mouseY)
|
||||
if not self._element then
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
if not self._scrollbarDragging then
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
local dims = self:calculateScrollbarDimensions()
|
||||
local element = self._element
|
||||
|
||||
|
||||
if self._hoveredScrollbar == "vertical" then
|
||||
local contentY = element.y + 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
|
||||
@@ -473,19 +466,19 @@ function ScrollManager:handleMouseMove(mouseX, mouseY)
|
||||
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
|
||||
|
||||
@@ -496,12 +489,12 @@ function ScrollManager:handleMouseRelease(button)
|
||||
if button ~= 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
if self._scrollbarDragging then
|
||||
self._scrollbarDragging = false
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -513,39 +506,39 @@ function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component)
|
||||
if not self._element then
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
local dims = self:calculateScrollbarDimensions()
|
||||
local element = self._element
|
||||
|
||||
|
||||
if component == "vertical" then
|
||||
local contentY = element.y + 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 = element.x + 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
|
||||
@@ -557,16 +550,16 @@ end
|
||||
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
|
||||
@@ -574,7 +567,7 @@ function ScrollManager:handleWheel(x, y)
|
||||
self:setScroll(nil, newScrollY)
|
||||
scrolled = true
|
||||
end
|
||||
|
||||
|
||||
-- Horizontal scrolling
|
||||
if x ~= 0 and hasHorizontalOverflow then
|
||||
local delta = -x * self.scrollSpeed
|
||||
@@ -582,7 +575,7 @@ function ScrollManager:handleWheel(x, y)
|
||||
self:setScroll(newScrollX, nil)
|
||||
scrolled = true
|
||||
end
|
||||
|
||||
|
||||
return scrolled
|
||||
end
|
||||
|
||||
@@ -591,7 +584,7 @@ end
|
||||
---@param mouseY number
|
||||
function ScrollManager:updateHoverState(mouseX, mouseY)
|
||||
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY)
|
||||
|
||||
|
||||
if scrollbar then
|
||||
if scrollbar.component == "vertical" then
|
||||
self._scrollbarHoveredVertical = true
|
||||
@@ -640,7 +633,7 @@ function ScrollManager:setState(state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
if state.scrollX then
|
||||
self._scrollX = state.scrollX
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user