Files
Basalt2/src/elements/ScrollFrame.lua
Robert Jelic 2ca7ad1e4c - Added comprehensive state management with conditional states, priority-based resolution, and property overrides
- Added responsive.lua with fluent builder API (:when()/:apply()/:otherwise()) for creating responsive layouts that react to parent size or custom conditions
- All elements now use getResolved() to check active states, enabling multiple responsive rules to coexist
2025-11-04 22:40:37 +01:00

430 lines
17 KiB
Lua

local elementManager = require("elementManager")
local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex")
---@configDescription A scrollable container that automatically displays scrollbars when content overflows.
---@configDefault false
--- A container that provides automatic scrolling capabilities with visual scrollbars. Displays vertical and/or horizontal scrollbars when child content exceeds the container's dimensions.
--- @run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- -- Create a ScrollFrame with content larger than the frame
--- local scrollFrame = main:addScrollFrame({
--- x = 2,
--- y = 2,
--- width = 30,
--- height = 12,
--- background = colors.lightGray
--- })
---
--- -- Add a title
--- scrollFrame:addLabel({
--- x = 2,
--- y = 1,
--- text = "ScrollFrame Example",
--- foreground = colors.yellow
--- })
---
--- -- Add multiple labels that exceed the frame height
--- for i = 1, 20 do
--- scrollFrame:addLabel({
--- x = 2,
--- y = i + 2,
--- text = "Line " .. i .. " - Scroll to see more",
--- foreground = i % 2 == 0 and colors.white or colors.lightGray
--- })
--- end
---
--- -- Add some interactive buttons at different positions
--- scrollFrame:addButton({
--- x = 2,
--- y = 24,
--- width = 15,
--- height = 3,
--- text = "Button 1",
--- background = colors.blue
--- })
--- :onClick(function()
--- scrollFrame:addLabel({
--- x = 18,
--- y = 24,
--- text = "Clicked!",
--- foreground = colors.lime
--- })
--- end)
---
--- scrollFrame:addButton({
--- x = 2,
--- y = 28,
--- width = 15,
--- height = 3,
--- text = "Button 2",
--- background = colors.green
--- })
--- :onClick(function()
--- scrollFrame:addLabel({
--- x = 18,
--- y = 28,
--- text = "Nice!",
--- foreground = colors.orange
--- })
--- end)
---
--- -- Info label outside the scroll frame
--- main:addLabel({
--- x = 2,
--- y = 15,
--- text = "Use mouse wheel to scroll!",
--- foreground = colors.gray
--- })
---
--- basalt.run()
--- ]]
---@class ScrollFrame : Container
local ScrollFrame = setmetatable({}, Container)
ScrollFrame.__index = ScrollFrame
---@property showScrollBar boolean true Whether to show scrollbars
ScrollFrame.defineProperty(ScrollFrame, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string "_" The symbol used for the scrollbar handle
ScrollFrame.defineProperty(ScrollFrame, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackgroundSymbol string "\127" The symbol used for the scrollbar background
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundSymbol", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
ScrollFrame.defineProperty(ScrollFrame, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor2 secondary color black Background color of the scrollbar
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundColor2", {default = colors.black, type = "color", canTriggerRender = true})
---@property contentWidth number 0 The total width of the content (calculated from children)
ScrollFrame.defineProperty(ScrollFrame, "contentWidth", {
default = 0,
type = "number",
getter = function(self)
local maxWidth = 0
local children = self.getResolved("children")
for _, child in ipairs(children) do
local childX = child.get("x")
local childWidth = child.get("width")
local childRight = childX + childWidth - 1
if childRight > maxWidth then
maxWidth = childRight
end
end
return maxWidth
end
})
---@property contentHeight number 0 The total height of the content (calculated from children)
ScrollFrame.defineProperty(ScrollFrame, "contentHeight", {
default = 0,
type = "number",
getter = function(self)
local maxHeight = 0
local children = self.getResolved("children")
for _, child in ipairs(children) do
local childY = child.get("y")
local childHeight = child.get("height")
local childBottom = childY + childHeight - 1
if childBottom > maxHeight then
maxHeight = childBottom
end
end
return maxHeight
end
})
ScrollFrame.defineEvent(ScrollFrame, "mouse_click")
ScrollFrame.defineEvent(ScrollFrame, "mouse_drag")
ScrollFrame.defineEvent(ScrollFrame, "mouse_up")
ScrollFrame.defineEvent(ScrollFrame, "mouse_scroll")
--- Creates a new ScrollFrame instance
--- @shortDescription Creates a new ScrollFrame instance
--- @return ScrollFrame self The newly created ScrollFrame instance
--- @private
function ScrollFrame.new()
local self = setmetatable({}, ScrollFrame):__init()
self.class = ScrollFrame
self.set("width", 20)
self.set("height", 10)
self.set("z", 5)
return self
end
--- Initializes a ScrollFrame instance
--- @shortDescription Initializes a ScrollFrame instance
--- @param props table Initial properties
--- @param basalt table The basalt instance
--- @return ScrollFrame self The initialized ScrollFrame instance
--- @private
function ScrollFrame:init(props, basalt)
Container.init(self, props, basalt)
self.set("type", "ScrollFrame")
return self
end
--- Handles mouse click events for scrollbars and content
--- @shortDescription Handles mouse click events
--- @param button number The mouse button (1=left, 2=right, 3=middle)
--- @param x number The x-coordinate of the click
--- @param y number The y-coordinate of the click
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_click(button, x, y)
if Container.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local height = self.getResolved("height")
local showScrollBar = self.getResolved("showScrollBar")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = showScrollBar and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar and relX == width and (not needsHorizontalScrollBar or relY < height) then
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
local currentPercent = maxOffset > 0 and (self.getResolved("offsetY") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
if relY >= handlePos and relY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = relY - handlePos
else
local newPercent = ((relY - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetY", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if needsHorizontalScrollBar and relY == height and (not needsVerticalScrollBar or relX < width) then
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
local currentPercent = maxOffset > 0 and (self.getResolved("offsetX") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
if relX >= handlePos and relX < handlePos + handleSize then
self._hScrollBarDragging = true
self._hScrollBarDragOffset = relX - handlePos
else
local newPercent = ((relX - 1) / (scrollWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetX", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
return true
end
return false
end
--- Handles mouse drag events for scrollbar
--- @shortDescription Handles mouse drag events for scrollbar
--- @param button number The mouse button being dragged
--- @param x number The x-coordinate of the drag
--- @param y number The y-coordinate of the drag
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local height = self.getResolved("height")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local width = self.getResolved("width")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
relY = math.max(1, math.min(scrollHeight, relY))
local newPos = relY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetY", math.max(0, math.min(maxOffset, newOffset)))
return true
end
if self._hScrollBarDragging then
local relX, _ = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local height = self.getResolved("height")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.getResolved("showScrollBar") and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
relX = math.max(1, math.min(scrollWidth, relX))
local newPos = relX - (self._hScrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetX", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return Container.mouse_drag and Container.mouse_drag(self, button, x, y) or false
end
--- Handles mouse up events to stop scrollbar dragging
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
if self._hScrollBarDragging then
self._hScrollBarDragging = false
self._hScrollBarDragOffset = nil
return true
end
return Container.mouse_up and Container.mouse_up(self, button, x, y) or false
end
--- Handles mouse scroll events
--- @shortDescription Handles mouse scroll events
--- @param direction number 1 for up, -1 for down
--- @param x number Mouse x position relative to element
--- @param y number Mouse y position relative to element
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then
local xOffset, yOffset = self.getResolved("offsetX"), self.getResolved("offsetY")
local relX, relY = self:getRelativePosition(x + xOffset, y + yOffset)
local success, child = self:callChildrenEvent(true, "mouse_scroll", direction, relX, relY)
if success then
return true
end
local height = self.getResolved("height")
local width = self.getResolved("width")
local offsetY = self.getResolved("offsetY")
local offsetX = self.getResolved("offsetX")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.getResolved("showScrollBar") and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar then
local maxScroll = math.max(0, contentHeight - viewportHeight)
local newScroll = math.min(maxScroll, math.max(0, offsetY + direction))
self.set("offsetY", newScroll)
elseif needsHorizontalScrollBar then
local maxScroll = math.max(0, contentWidth - viewportWidth)
local newScroll = math.min(maxScroll, math.max(0, offsetX + direction))
self.set("offsetX", newScroll)
end
return true
end
return false
end
--- Renders the ScrollFrame and its scrollbars
--- @shortDescription Renders the ScrollFrame and its scrollbars
--- @protected
function ScrollFrame:render()
Container.render(self)
local height = self.getResolved("height")
local width = self.getResolved("width")
local offsetY = self.getResolved("offsetY")
local offsetX = self.getResolved("offsetX")
local showScrollBar = self.getResolved("showScrollBar")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = showScrollBar and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar then
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
local scrollBarBg = self.getResolved("scrollBarBackgroundSymbol")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local scrollBarBg2Color = self.getResolved("scrollBarBackgroundColor2")
local currentPercent = maxOffset > 0 and (offsetY / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
for i = 1, scrollHeight do
if i >= handlePos and i < handlePos + handleSize then
self:blit(width, i, " ", tHex[scrollBarColor], tHex[scrollBarColor])
else
self:blit(width, i, scrollBarBg, tHex[scrollBarBgColor], tHex[scrollBarBg2Color])
end
end
end
if needsHorizontalScrollBar then
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
local scrollBarBg = self.getResolved("scrollBarBackgroundSymbol")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local scrollBarBg2Color = self.getResolved("scrollBarBackgroundColor2")
local currentPercent = maxOffset > 0 and (offsetX / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
for i = 1, scrollWidth do
if i >= handlePos and i < handlePos + handleSize then
self:blit(i, height, " ", tHex[scrollBarColor], tHex[scrollBarColor])
else
self:blit(i, height, scrollBarBg, tHex[scrollBarBgColor], tHex[scrollBarBg2Color])
end
end
end
if needsVerticalScrollBar and needsHorizontalScrollBar then
local background = self.getResolved("background")
self:blit(width, height, " ", tHex[background], tHex[background])
end
end
return ScrollFrame