start of scrollable work
This commit is contained in:
647
FlexLove.lua
647
FlexLove.lua
@@ -2563,6 +2563,49 @@ function Gui.keypressed(key, scancode, isrepeat)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Handle mouse wheel scrolling
|
||||||
|
function Gui.wheelmoved(x, y)
|
||||||
|
-- Get mouse position
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
|
||||||
|
-- Find the deepest scrollable element at mouse position
|
||||||
|
local function findScrollableAtPosition(elements, mx, my)
|
||||||
|
-- Check in reverse z-order (top to bottom)
|
||||||
|
for i = #elements, 1, -1 do
|
||||||
|
local element = elements[i]
|
||||||
|
|
||||||
|
-- Check if mouse is over element
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
|
||||||
|
-- Check children first (depth-first)
|
||||||
|
if #element.children > 0 then
|
||||||
|
local childResult = findScrollableAtPosition(element.children, mx, my)
|
||||||
|
if childResult then return childResult end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if this element is scrollable
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") and
|
||||||
|
(element._overflowX or element._overflowY) then
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local scrollableElement = findScrollableAtPosition(Gui.topElements, mx, my)
|
||||||
|
if scrollableElement then
|
||||||
|
scrollableElement:_handleWheelScroll(x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
--- Destroy all elements and their children
|
--- Destroy all elements and their children
|
||||||
function Gui.destroy()
|
function Gui.destroy()
|
||||||
for _, win in ipairs(Gui.topElements) do
|
for _, win in ipairs(Gui.topElements) do
|
||||||
@@ -3113,6 +3156,15 @@ Element.__index = Element
|
|||||||
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
||||||
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
||||||
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
|
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
|
||||||
|
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "visible")
|
||||||
|
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
|
||||||
|
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
|
||||||
|
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
|
||||||
|
---@field scrollbarColor Color? -- Scrollbar thumb color
|
||||||
|
---@field scrollbarTrackColor Color? -- Scrollbar track color
|
||||||
|
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
|
||||||
|
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
|
||||||
|
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
|
||||||
local ElementProps = {}
|
local ElementProps = {}
|
||||||
|
|
||||||
---@param props ElementProps
|
---@param props ElementProps
|
||||||
@@ -4055,6 +4107,37 @@ function Element.new(props)
|
|||||||
self.transform = props.transform or {}
|
self.transform = props.transform or {}
|
||||||
self.transition = props.transition or {}
|
self.transition = props.transition or {}
|
||||||
|
|
||||||
|
-- Overflow and scroll properties
|
||||||
|
self.overflow = props.overflow or "visible"
|
||||||
|
self.overflowX = props.overflowX
|
||||||
|
self.overflowY = props.overflowY
|
||||||
|
|
||||||
|
-- Scrollbar configuration
|
||||||
|
self.scrollbarWidth = props.scrollbarWidth or 12
|
||||||
|
self.scrollbarColor = props.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8)
|
||||||
|
self.scrollbarTrackColor = props.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5)
|
||||||
|
self.scrollbarRadius = props.scrollbarRadius or 6
|
||||||
|
self.scrollbarPadding = props.scrollbarPadding or 2
|
||||||
|
self.scrollSpeed = props.scrollSpeed or 20
|
||||||
|
|
||||||
|
-- Internal overflow state
|
||||||
|
self._overflowX = false
|
||||||
|
self._overflowY = false
|
||||||
|
self._contentWidth = 0
|
||||||
|
self._contentHeight = 0
|
||||||
|
|
||||||
|
-- Scroll state
|
||||||
|
self._scrollX = 0
|
||||||
|
self._scrollY = 0
|
||||||
|
self._maxScrollX = 0
|
||||||
|
self._maxScrollY = 0
|
||||||
|
|
||||||
|
-- Scrollbar interaction state
|
||||||
|
self._scrollbarHovered = false
|
||||||
|
self._scrollbarDragging = false
|
||||||
|
self._hoveredScrollbar = nil -- "vertical" or "horizontal"
|
||||||
|
self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started
|
||||||
|
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4076,6 +4159,494 @@ function Element:getBorderBoxHeight()
|
|||||||
return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Detect if content overflows container bounds
|
||||||
|
function Element:_detectOverflow()
|
||||||
|
-- Reset overflow state
|
||||||
|
self._overflowX = false
|
||||||
|
self._overflowY = false
|
||||||
|
self._contentWidth = self.width
|
||||||
|
self._contentHeight = self.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.children == 0 then
|
||||||
|
return -- No children, no overflow
|
||||||
|
end
|
||||||
|
|
||||||
|
local minX, minY = math.huge, math.huge
|
||||||
|
local maxX, maxY = -math.huge, -math.huge
|
||||||
|
|
||||||
|
for _, child in ipairs(self.children) do
|
||||||
|
-- Skip absolutely positioned children (they don't contribute to overflow)
|
||||||
|
if not child._explicitlyAbsolute then
|
||||||
|
local childLeft = child.x - self.x
|
||||||
|
local childTop = child.y - self.y
|
||||||
|
local childRight = childLeft + child:getBorderBoxWidth() + child.margin.left + child.margin.right
|
||||||
|
local childBottom = childTop + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom
|
||||||
|
|
||||||
|
minX = math.min(minX, childLeft)
|
||||||
|
minY = math.min(minY, childTop)
|
||||||
|
maxX = math.max(maxX, childRight)
|
||||||
|
maxY = math.max(maxY, childBottom)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If no non-absolute children, no overflow
|
||||||
|
if minX == math.huge then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate content dimensions
|
||||||
|
self._contentWidth = math.max(0, maxX - minX)
|
||||||
|
self._contentHeight = math.max(0, maxY - minY)
|
||||||
|
|
||||||
|
-- Detect overflow
|
||||||
|
local containerWidth = self.width
|
||||||
|
local containerHeight = self.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 Element:setScrollPosition(x, y)
|
||||||
|
if x ~= nil then
|
||||||
|
self._scrollX = math.max(0, math.min(x, self._maxScrollX))
|
||||||
|
end
|
||||||
|
if y ~= nil then
|
||||||
|
self._scrollY = math.max(0, math.min(y, self._maxScrollY))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Calculate scrollbar dimensions and positions
|
||||||
|
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
|
||||||
|
function Element:_calculateScrollbarDimensions()
|
||||||
|
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
|
||||||
|
if self._overflowY and (overflowY == "scroll" or overflowY == "auto") then
|
||||||
|
result.vertical.visible = true
|
||||||
|
result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2)
|
||||||
|
|
||||||
|
-- Calculate thumb height based on content ratio
|
||||||
|
local contentRatio = self.height / math.max(self._contentHeight, self.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
|
||||||
|
elseif overflowY == "scroll" then
|
||||||
|
-- Always show scrollbar for "scroll" mode even without overflow
|
||||||
|
result.vertical.visible = true
|
||||||
|
result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2)
|
||||||
|
result.vertical.thumbHeight = result.vertical.trackHeight
|
||||||
|
result.vertical.thumbY = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Horizontal scrollbar
|
||||||
|
if self._overflowX and (overflowX == "scroll" or overflowX == "auto") then
|
||||||
|
result.horizontal.visible = true
|
||||||
|
result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2)
|
||||||
|
|
||||||
|
-- Calculate thumb width based on content ratio
|
||||||
|
local contentRatio = self.width / math.max(self._contentWidth, self.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
|
||||||
|
elseif overflowX == "scroll" then
|
||||||
|
-- Always show scrollbar for "scroll" mode even without overflow
|
||||||
|
result.horizontal.visible = true
|
||||||
|
result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2)
|
||||||
|
result.horizontal.thumbWidth = result.horizontal.trackWidth
|
||||||
|
result.horizontal.thumbX = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw scrollbars
|
||||||
|
---@param dims table -- Scrollbar dimensions from _calculateScrollbarDimensions()
|
||||||
|
function Element:_drawScrollbars(dims)
|
||||||
|
local x, y = self.x, self.y
|
||||||
|
local w, h = self.width, self.height
|
||||||
|
|
||||||
|
-- Determine thumb color based on state
|
||||||
|
local thumbColor = self.scrollbarColor
|
||||||
|
if self._scrollbarDragging 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._scrollbarHovered 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
|
||||||
|
|
||||||
|
-- Vertical scrollbar
|
||||||
|
if dims.vertical.visible then
|
||||||
|
local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left
|
||||||
|
local trackY = y + self.scrollbarPadding + self.padding.top
|
||||||
|
|
||||||
|
-- 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 then
|
||||||
|
local trackX = x + self.scrollbarPadding + self.padding.left
|
||||||
|
local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top
|
||||||
|
|
||||||
|
-- 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 Element:_getScrollbarAtPosition(mouseX, mouseY)
|
||||||
|
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.x, self.y
|
||||||
|
local w, h = self.width, self.height
|
||||||
|
|
||||||
|
-- Check vertical scrollbar
|
||||||
|
if dims.vertical.visible then
|
||||||
|
local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left
|
||||||
|
local trackY = y + self.scrollbarPadding + self.padding.top
|
||||||
|
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
|
||||||
|
if dims.horizontal.visible then
|
||||||
|
local trackX = x + self.scrollbarPadding + self.padding.left
|
||||||
|
local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top
|
||||||
|
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 Element:_handleScrollbarPress(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 trackY = self.y + self.scrollbarPadding + self.padding.top
|
||||||
|
local thumbY = trackY + dims.vertical.thumbY
|
||||||
|
self._scrollbarDragOffset = mouseY - thumbY
|
||||||
|
elseif scrollbar.component == "horizontal" then
|
||||||
|
local trackX = self.x + self.scrollbarPadding + self.padding.left
|
||||||
|
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 drag
|
||||||
|
---@param mouseX number
|
||||||
|
---@param mouseY number
|
||||||
|
---@return boolean -- True if event was consumed
|
||||||
|
function Element:_handleScrollbarDrag(mouseX, mouseY)
|
||||||
|
if not self._scrollbarDragging then return false end
|
||||||
|
|
||||||
|
local dims = self:_calculateScrollbarDimensions()
|
||||||
|
|
||||||
|
if self._hoveredScrollbar == "vertical" then
|
||||||
|
local trackY = self.y + self.scrollbarPadding + self.padding.top
|
||||||
|
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:setScrollPosition(nil, newScrollY)
|
||||||
|
return true
|
||||||
|
|
||||||
|
elseif self._hoveredScrollbar == "horizontal" then
|
||||||
|
local trackX = self.x + self.scrollbarPadding + self.padding.left
|
||||||
|
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:setScrollPosition(newScrollX, nil)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle scrollbar release
|
||||||
|
---@param button number
|
||||||
|
---@return boolean -- True if event was consumed
|
||||||
|
function Element:_handleScrollbarRelease(button)
|
||||||
|
if button ~= 1 then return false end
|
||||||
|
|
||||||
|
if self._scrollbarDragging then
|
||||||
|
self._scrollbarDragging = false
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll to track click position
|
||||||
|
---@param mouseX number
|
||||||
|
---@param mouseY number
|
||||||
|
---@param component string -- "vertical" or "horizontal"
|
||||||
|
function Element:_scrollToTrackPosition(mouseX, mouseY, component)
|
||||||
|
local dims = self:_calculateScrollbarDimensions()
|
||||||
|
|
||||||
|
if component == "vertical" then
|
||||||
|
local trackY = self.y + self.scrollbarPadding + self.padding.top
|
||||||
|
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:setScrollPosition(nil, newScrollY)
|
||||||
|
|
||||||
|
elseif component == "horizontal" then
|
||||||
|
local trackX = self.x + self.scrollbarPadding + self.padding.left
|
||||||
|
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:setScrollPosition(newScrollX, nil)
|
||||||
|
end
|
||||||
|
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 Element:_handleWheelScroll(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:setScrollPosition(nil, newScrollY)
|
||||||
|
scrolled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Horizontal scrolling
|
||||||
|
if x ~= 0 and hasHorizontalOverflow then
|
||||||
|
local delta = -x * self.scrollSpeed
|
||||||
|
local newScrollX = self._scrollX + delta
|
||||||
|
self:setScrollPosition(newScrollX, nil)
|
||||||
|
scrolled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return scrolled
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get current scroll position
|
||||||
|
---@return number scrollX, number scrollY
|
||||||
|
function Element:getScrollPosition()
|
||||||
|
return self._scrollX, self._scrollY
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get maximum scroll bounds
|
||||||
|
---@return number maxScrollX, number maxScrollY
|
||||||
|
function Element:getMaxScroll()
|
||||||
|
return self._maxScrollX, self._maxScrollY
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get scroll percentage (0-1)
|
||||||
|
---@return number percentX, number percentY
|
||||||
|
function Element: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
|
||||||
|
|
||||||
|
--- Check if element has overflow
|
||||||
|
---@return boolean hasOverflowX, boolean hasOverflowY
|
||||||
|
function Element:hasOverflow()
|
||||||
|
return self._overflowX, self._overflowY
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get content dimensions (including overflow)
|
||||||
|
---@return number contentWidth, number contentHeight
|
||||||
|
function Element:getContentSize()
|
||||||
|
return self._contentWidth, self._contentHeight
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll by delta amount
|
||||||
|
---@param dx number? -- X delta (nil for no change)
|
||||||
|
---@param dy number? -- Y delta (nil for no change)
|
||||||
|
function Element:scrollBy(dx, dy)
|
||||||
|
if dx then
|
||||||
|
self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX))
|
||||||
|
end
|
||||||
|
if dy then
|
||||||
|
self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll to top
|
||||||
|
function Element:scrollToTop()
|
||||||
|
self:setScrollPosition(nil, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll to bottom
|
||||||
|
function Element:scrollToBottom()
|
||||||
|
self:setScrollPosition(nil, self._maxScrollY)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll to left
|
||||||
|
function Element:scrollToLeft()
|
||||||
|
self:setScrollPosition(0, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scroll to right
|
||||||
|
function Element:scrollToRight()
|
||||||
|
self:setScrollPosition(self._maxScrollX, nil)
|
||||||
|
end
|
||||||
|
|
||||||
--- Get the current state's scaled content padding
|
--- Get the current state's scaled content padding
|
||||||
--- Returns the contentPadding for the current theme state, scaled to the element's size
|
--- Returns the contentPadding for the current theme state, scaled to the element's size
|
||||||
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
|
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
|
||||||
@@ -4673,6 +5244,9 @@ function Element:layoutChildren()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Detect overflow after children are laid out
|
||||||
|
self:_detectOverflow()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Destroy element and its children
|
--- Destroy element and its children
|
||||||
@@ -5013,6 +5587,19 @@ function Element:draw(backdropCanvas)
|
|||||||
|
|
||||||
-- Helper function to draw children (with or without clipping)
|
-- Helper function to draw children (with or without clipping)
|
||||||
local function drawChildren()
|
local function drawChildren()
|
||||||
|
-- Determine if we need overflow clipping
|
||||||
|
local overflowX = self.overflowX or self.overflow
|
||||||
|
local overflowY = self.overflowY or self.overflow
|
||||||
|
local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil)
|
||||||
|
|
||||||
|
-- Apply scroll offset if overflow is not visible
|
||||||
|
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0)
|
||||||
|
|
||||||
|
if hasScrollOffset then
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(-self._scrollX, -self._scrollY)
|
||||||
|
end
|
||||||
|
|
||||||
if hasRoundedCorners and #sortedChildren > 0 then
|
if hasRoundedCorners and #sortedChildren > 0 then
|
||||||
-- Use stencil to clip children to rounded rectangle
|
-- Use stencil to clip children to rounded rectangle
|
||||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
|
||||||
@@ -5028,12 +5615,30 @@ function Element:draw(backdropCanvas)
|
|||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setStencilTest()
|
love.graphics.setStencilTest()
|
||||||
|
elseif needsOverflowClipping and #sortedChildren > 0 then
|
||||||
|
-- Clip content for overflow hidden/scroll/auto without rounded corners
|
||||||
|
local contentX = self.x + self.padding.left
|
||||||
|
local contentY = self.y + self.padding.top
|
||||||
|
local contentWidth = self.width
|
||||||
|
local contentHeight = self.height
|
||||||
|
|
||||||
|
love.graphics.setScissor(contentX, contentY, contentWidth, contentHeight)
|
||||||
|
|
||||||
|
for _, child in ipairs(sortedChildren) do
|
||||||
|
child:draw(backdropCanvas)
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.setScissor()
|
||||||
else
|
else
|
||||||
-- No clipping needed
|
-- No clipping needed
|
||||||
for _, child in ipairs(sortedChildren) do
|
for _, child in ipairs(sortedChildren) do
|
||||||
child:draw(backdropCanvas)
|
child:draw(backdropCanvas)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if hasScrollOffset then
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Apply content blur if configured
|
-- Apply content blur if configured
|
||||||
@@ -5047,6 +5652,16 @@ function Element:draw(backdropCanvas)
|
|||||||
else
|
else
|
||||||
drawChildren()
|
drawChildren()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Draw scrollbars if overflow is scroll or auto
|
||||||
|
local overflowX = self.overflowX or self.overflow
|
||||||
|
local overflowY = self.overflowY or self.overflow
|
||||||
|
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||||
|
local scrollbarDims = self:_calculateScrollbarDimensions()
|
||||||
|
if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then
|
||||||
|
self:_drawScrollbars(scrollbarDims)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Update element (propagate to children)
|
--- Update element (propagate to children)
|
||||||
@@ -5083,9 +5698,30 @@ function Element:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Handle scrollbar hover detection
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
local scrollbar = self:_getScrollbarAtPosition(mx, my)
|
||||||
|
local wasHovered = self._scrollbarHovered
|
||||||
|
if scrollbar then
|
||||||
|
self._scrollbarHovered = true
|
||||||
|
self._hoveredScrollbar = scrollbar.component
|
||||||
|
else
|
||||||
|
if not self._scrollbarDragging then
|
||||||
|
self._scrollbarHovered = false
|
||||||
|
self._hoveredScrollbar = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle scrollbar dragging
|
||||||
|
if self._scrollbarDragging and love.mouse.isDown(1) then
|
||||||
|
self:_handleScrollbarDrag(mx, my)
|
||||||
|
elseif self._scrollbarDragging then
|
||||||
|
-- Mouse button released
|
||||||
|
self._scrollbarDragging = false
|
||||||
|
end
|
||||||
|
|
||||||
-- Handle click detection for element with enhanced event system
|
-- Handle click detection for element with enhanced event system
|
||||||
if self.callback or self.themeComponent then
|
if self.callback or self.themeComponent then
|
||||||
local mx, my = love.mouse.getPosition()
|
|
||||||
-- Clickable area is the border box (x, y already includes padding)
|
-- Clickable area is the border box (x, y already includes padding)
|
||||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
||||||
local bx = self.x
|
local bx = self.x
|
||||||
@@ -5134,6 +5770,11 @@ function Element:update(dt)
|
|||||||
if love.mouse.isDown(button) then
|
if love.mouse.isDown(button) then
|
||||||
-- Button is pressed down
|
-- Button is pressed down
|
||||||
if not self._pressed[button] then
|
if not self._pressed[button] then
|
||||||
|
-- Check if press is on scrollbar first
|
||||||
|
if button == 1 and self:_handleScrollbarPress(mx, my, button) then
|
||||||
|
-- Scrollbar consumed the event, mark as pressed to prevent callback
|
||||||
|
self._pressed[button] = true
|
||||||
|
else
|
||||||
-- Just pressed - fire press event and record drag start position
|
-- Just pressed - fire press event and record drag start position
|
||||||
local modifiers = getModifiers()
|
local modifiers = getModifiers()
|
||||||
local pressEvent = InputEvent.new({
|
local pressEvent = InputEvent.new({
|
||||||
@@ -5146,6 +5787,7 @@ function Element:update(dt)
|
|||||||
})
|
})
|
||||||
self.callback(self, pressEvent)
|
self.callback(self, pressEvent)
|
||||||
self._pressed[button] = true
|
self._pressed[button] = true
|
||||||
|
end
|
||||||
|
|
||||||
-- Record drag start position per button
|
-- Record drag start position per button
|
||||||
self._dragStartX[button] = mx
|
self._dragStartX[button] = mx
|
||||||
@@ -5525,6 +6167,9 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight)
|
|||||||
self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom
|
self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom
|
||||||
end
|
end
|
||||||
-- For pixel units, height stays as-is (may have been manually modified)
|
-- For pixel units, height stays as-is (may have been manually modified)
|
||||||
|
|
||||||
|
-- Detect overflow after layout calculations
|
||||||
|
self:_detectOverflow()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Resize element and its children based on game window size change
|
--- Resize element and its children based on game window size change
|
||||||
|
|||||||
127
examples/15_scrollable_elements.lua
Normal file
127
examples/15_scrollable_elements.lua
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
-- Example 15: Scrollable Elements
|
||||||
|
-- Demonstrates scrollable containers with overflow detection and visual scrollbars
|
||||||
|
|
||||||
|
local FlexLove = require("FlexLove")
|
||||||
|
local Gui = FlexLove.Gui
|
||||||
|
local Color = FlexLove.Color
|
||||||
|
local enums = FlexLove.enums
|
||||||
|
local Lv = love
|
||||||
|
|
||||||
|
function Lv.load()
|
||||||
|
Gui.init({
|
||||||
|
baseScale = { width = 1920, height = 1080 },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Title
|
||||||
|
Gui.new({
|
||||||
|
x = "2vw",
|
||||||
|
y = "2vh",
|
||||||
|
width = "96vw",
|
||||||
|
height = "6vh",
|
||||||
|
text = "FlexLove Example 15: Scrollable Elements",
|
||||||
|
textSize = "4vh",
|
||||||
|
textColor = Color.new(1, 1, 1, 1),
|
||||||
|
textAlign = enums.TextAlign.CENTER,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Example 1: Vertical scroll with auto scrollbars
|
||||||
|
local verticalScroll = Gui.new({
|
||||||
|
x = "5vw",
|
||||||
|
y = "12vh",
|
||||||
|
width = "25vw",
|
||||||
|
height = "35vh",
|
||||||
|
overflow = "auto",
|
||||||
|
backgroundColor = Color.new(0.15, 0.15, 0.2, 1),
|
||||||
|
cornerRadius = 8,
|
||||||
|
positioning = enums.Positioning.FLEX,
|
||||||
|
flexDirection = enums.FlexDirection.VERTICAL,
|
||||||
|
gap = 5,
|
||||||
|
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Add many items to create overflow
|
||||||
|
for i = 1, 20 do
|
||||||
|
Gui.new({
|
||||||
|
parent = verticalScroll,
|
||||||
|
height = "5vh",
|
||||||
|
backgroundColor = Color.new(0.3 + (i % 3) * 0.1, 0.4, 0.6, 1),
|
||||||
|
cornerRadius = 4,
|
||||||
|
text = "Item " .. i,
|
||||||
|
textColor = Color.new(1, 1, 1, 1),
|
||||||
|
textAlign = enums.TextAlign.CENTER,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Example 2: Custom styled scrollbar
|
||||||
|
local customScroll = Gui.new({
|
||||||
|
x = "35vw",
|
||||||
|
y = "12vh",
|
||||||
|
width = "60vw",
|
||||||
|
height = "35vh",
|
||||||
|
overflow = "auto",
|
||||||
|
backgroundColor = Color.new(0.1, 0.1, 0.15, 1),
|
||||||
|
cornerRadius = 8,
|
||||||
|
scrollbarWidth = 16,
|
||||||
|
scrollbarColor = Color.new(0.3, 0.6, 0.9, 1),
|
||||||
|
scrollbarTrackColor = Color.new(0.15, 0.15, 0.2, 0.8),
|
||||||
|
scrollbarRadius = 8,
|
||||||
|
positioning = enums.Positioning.FLEX,
|
||||||
|
flexDirection = enums.FlexDirection.VERTICAL,
|
||||||
|
gap = 10,
|
||||||
|
padding = { top = 15, right = 15, bottom = 15, left = 15 },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Add content
|
||||||
|
for i = 1, 25 do
|
||||||
|
Gui.new({
|
||||||
|
parent = customScroll,
|
||||||
|
height = "6vh",
|
||||||
|
backgroundColor = Color.new(0.2, 0.25, 0.3, 1),
|
||||||
|
cornerRadius = 6,
|
||||||
|
text = "Custom Scrollbar Item " .. i,
|
||||||
|
textColor = Color.new(0.9, 0.9, 1, 1),
|
||||||
|
textSize = "2vh",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Instructions
|
||||||
|
Gui.new({
|
||||||
|
x = "5vw",
|
||||||
|
y = "52vh",
|
||||||
|
width = "90vw",
|
||||||
|
height = "40vh",
|
||||||
|
backgroundColor = Color.new(0.1, 0.15, 0.2, 0.9),
|
||||||
|
cornerRadius = 8,
|
||||||
|
padding = { top = 15, right = 15, bottom = 15, left = 15 },
|
||||||
|
text = [[Instructions:
|
||||||
|
• Use mouse wheel to scroll elements under cursor
|
||||||
|
• Click and drag scrollbar thumb to scroll
|
||||||
|
• Click on scrollbar track to jump to position
|
||||||
|
• Scrollbars automatically appear when content overflows
|
||||||
|
• overflow="auto" shows scrollbars only when needed
|
||||||
|
• overflow="scroll" always shows scrollbars
|
||||||
|
• overflow="hidden" clips without scrollbars
|
||||||
|
• overflow="visible" shows all content (default)
|
||||||
|
|
||||||
|
Scrollbar colors change on hover and when dragging!]],
|
||||||
|
textColor = Color.new(0.9, 0.9, 1, 1),
|
||||||
|
textSize = "2vh",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function Lv.update(dt)
|
||||||
|
Gui.update(dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Lv.draw()
|
||||||
|
love.graphics.clear(0.05, 0.05, 0.08, 1)
|
||||||
|
Gui.draw()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Lv.resize(w, h)
|
||||||
|
Gui.resize(w, h)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Lv.wheelmoved(x, y)
|
||||||
|
Gui.wheelmoved(x, y)
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user