Files
Basalt2/src/elements/Container.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

754 lines
27 KiB
Lua

local elementManager = require("elementManager")
local errorManager = require("errorManager")
local VisualElement = elementManager.getElement("VisualElement")
local LayoutManager = require("layoutManager")
local expect = require("libraries/expect")
local split = require("libraries/utils").split
---@configDescription The container class. It is a visual element that can contain other elements. It is the base class for all containers
---@configDefault true
--- A fundamental layout element that manages child UI components. Containers handle element organization, event propagation, rendering hierarchy, and coordinate space management.
---@class Container : VisualElement
local Container = setmetatable({}, VisualElement)
Container.__index = Container
---@property children table {} Collection of all child elements
Container.defineProperty(Container, "children", {default = {}, type = "table"})
---@property childrenSorted boolean true Indicates if children are sorted by z-index
Container.defineProperty(Container, "childrenSorted", {default = true, type = "boolean"})
---@property childrenEventsSorted boolean true Indicates if event handlers are properly sorted
Container.defineProperty(Container, "childrenEventsSorted", {default = true, type = "boolean"})
---@property childrenEvents table {} Registered event handlers for all children
Container.defineProperty(Container, "childrenEvents", {default = {}, type = "table"})
---@property eventListenerCount table {} Number of listeners per event type
Container.defineProperty(Container, "eventListenerCount", {default = {}, type = "table"})
---@property focusedChild table nil Currently focused child element (receives keyboard events)
Container.defineProperty(Container, "focusedChild", {default = nil, type = "table", allowNil=true, setter = function(self, value, internal)
local oldChild = self._values.focusedChild
if value == oldChild then return value end
if oldChild then
if oldChild:isType("Container") then
oldChild.set("focusedChild", nil, true)
end
oldChild:setFocused(false, true)
end
if value and not internal then
value:setFocused(true, true)
if self.parent then
self.parent:setFocusedChild(self)
end
end
return value
end})
---@property visibleChildren table {} Currently visible child elements (calculated based on viewport)
Container.defineProperty(Container, "visibleChildren", {default = {}, type = "table"})
---@property visibleChildrenEvents table {} Event handlers for currently visible children
Container.defineProperty(Container, "visibleChildrenEvents", {default = {}, type = "table"})
---@property offsetX number 0 Horizontal scroll/content offset
Container.defineProperty(Container, "offsetX", {default = 0, type = "number", canTriggerRender = true, setter=function(self, value)
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
return value
end})
---@property offsetY number 0 Vertical scroll/content offset
Container.defineProperty(Container, "offsetY", {default = 0, type = "number", canTriggerRender = true, setter=function(self, value)
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
return value
end})
---@combinedProperty offset {offsetX number, offsetY number} Combined property for offsetX and offsetY
Container.combineProperties(Container, "offset", "offsetX", "offsetY")
for k, _ in pairs(elementManager:getElementList()) do
local capitalizedName = k:sub(1,1):upper() .. k:sub(2)
if capitalizedName ~= "BaseFrame" then
Container["add"..capitalizedName] = function(self, ...)
expect(1, self, "table")
local element = self.basalt.create(k, ...)
self:addChild(element)
--element:postInit()
return element
end
Container["addDelayed"..capitalizedName] = function(self, prop)
expect(1, self, "table")
local element = self.basalt.create(k, prop, true, self)
return element
end
end
end
--- Creates a new Container instance
--- @shortDescription Creates a new Container instance
--- @return Container self The new container instance
--- @private
function Container.new()
local self = setmetatable({}, Container):__init()
self.class = Container
return self
end
--- @shortDescription Initializes the Container instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @protected
function Container:init(props, basalt)
VisualElement.init(self, props, basalt)
self.set("type", "Container")
self:observe("width", function()
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
self:updateRender()
end)
self:observe("height", function()
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
self:updateRender()
end)
end
--- Tests whether a child element is currently visible within the container's viewport
--- @shortDescription Checks if a child element is visible
--- @param child table The child element to check
--- @return boolean isVisible Whether the child is within view bounds
function Container:isChildVisible(child)
if not child:isType("VisualElement") then return false end
if(child.get("visible") == false)then return false end
if(child._destroyed)then return false end
local containerW, containerH = self.getResolved("width"), self.getResolved("height")
local offsetX, offsetY = self.getResolved("offsetX"), self.getResolved("offsetY")
local childX, childY = child.get("x"), child.get("y")
local childW, childH = child.get("width"), child.get("height")
local relativeX
local relativeY
if(child.get("ignoreOffset"))then
relativeX = childX
relativeY = childY
else
relativeX = childX - offsetX
relativeY = childY - offsetY
end
return (relativeX + childW > 0) and
(relativeX <= containerW) and
(relativeY + childH > 0) and
(relativeY <= containerH)
end
--- Adds a new element to this container's hierarchy
--- @shortDescription Adds a child element to the container
--- @param child table The element to add as a child
--- @return Container self For method chaining
function Container:addChild(child)
if child == self then
error("Cannot add container to itself")
end
if(child ~= nil)then
table.insert(self._values.children, child)
child.parent = self
child:postInit()
self.set("childrenSorted", false)
self:registerChildrenEvents(child)
end
return self
end
local function sortAndFilterChildren(self, children)
local visibleChildren = {}
for _, child in ipairs(children) do
if self:isChildVisible(child) and child.get("visible") and not child._destroyed then
table.insert(visibleChildren, child)
end
end
for i = 2, #visibleChildren do
local current = visibleChildren[i]
local currentZ = current.get("z")
local j = i - 1
while j > 0 do
local compare = visibleChildren[j].get("z")
if compare > currentZ then
visibleChildren[j + 1] = visibleChildren[j]
j = j - 1
else
break
end
end
visibleChildren[j + 1] = current
end
return visibleChildren
end
--- Removes all child elements and resets the container's state
--- @shortDescription Removes all children and resets container
--- @return Container self For method chaining
function Container:clear()
self.set("children", {})
self.set("childrenEvents", {})
self.set("visibleChildren", {})
self.set("visibleChildrenEvents", {})
self.set("childrenSorted", true)
self.set("childrenEventsSorted", true)
return self
end
--- Re-sorts children by their z-index and updates visibility
--- @shortDescription Updates child element ordering
--- @return Container self For method chaining
function Container:sortChildren()
self.set("childrenSorted", true)
if self._layoutInstance then
self:updateLayout()
end
self.set("visibleChildren", sortAndFilterChildren(self, self._values.children))
return self
end
--- Sorts the children events of the container
--- @shortDescription Sorts the children events of the container
--- @param eventName string The event name to sort
--- @return Container self The container instance
function Container:sortChildrenEvents(eventName)
if self._values.childrenEvents[eventName] then
self._values.visibleChildrenEvents[eventName] = sortAndFilterChildren(self, self._values.childrenEvents[eventName])
end
self.set("childrenEventsSorted", true)
return self
end
--- Registers the children events of the container
--- @shortDescription Registers the children events of the container
--- @param child table The child to register events for
--- @return Container self The container instance
function Container:registerChildrenEvents(child)
if(child._registeredEvents == nil)then return end
for event in pairs(child._registeredEvents) do
self:registerChildEvent(child, event)
end
return self
end
--- Registers an event handler for a specific child element
--- @shortDescription Sets up event handling for a child
--- @param child table The child element to register events for
--- @param eventName string The name of the event to register
--- @return Container self For method chaining
function Container:registerChildEvent(child, eventName)
if not self._values.childrenEvents[eventName] then
self._values.childrenEvents[eventName] = {}
self._values.eventListenerCount[eventName] = 0
if self.parent then
self.parent:registerChildEvent(self, eventName)
end
end
for _, registeredChild in ipairs(self._values.childrenEvents[eventName]) do
if registeredChild.get("id") == child.get("id") then
return self
end
end
self.set("childrenEventsSorted", false)
table.insert(self._values.childrenEvents[eventName], child)
self._values.eventListenerCount[eventName] = self._values.eventListenerCount[eventName] + 1
return self
end
--- Unregisters the children events of the container
--- @shortDescription Unregisters the children events of the container
--- @param child table The child to unregister events for
--- @return Container self The container instance
function Container:removeChildrenEvents(child)
if child ~= nil then
if(child._registeredEvents == nil)then return self end
for event in pairs(child._registeredEvents) do
self:unregisterChildEvent(child, event)
end
end
return self
end
--- Unregisters the children events of the container
--- @shortDescription Unregisters the children events of the container
--- @param child table The child to unregister events for
--- @param eventName string The event name to unregister
--- @return Container self The container instance
function Container:unregisterChildEvent(child, eventName)
if self._values.childrenEvents[eventName] then
for i, listener in ipairs(self._values.childrenEvents[eventName]) do
if listener.get("id") == child.get("id") then
table.remove(self._values.childrenEvents[eventName], i)
self._values.eventListenerCount[eventName] = self._values.eventListenerCount[eventName] - 1
if self._values.eventListenerCount[eventName] <= 0 then
self._values.childrenEvents[eventName] = nil
self._values.eventListenerCount[eventName] = nil
if self.parent then
self.parent:unregisterChildEvent(self, eventName)
end
end
self.set("childrenEventsSorted", false)
break
end
end
end
return self
end
--- Removes an element from this container's hierarchy and cleans up its events
--- @shortDescription Removes a child element from the container
--- @param child table The element to remove
--- @return Container self For method chaining
function Container:removeChild(child)
if child == nil then return self end
for i,v in ipairs(self._values.children) do
if v.get("id") == child.get("id") then
table.remove(self._values.children, i)
child.parent = nil
break
end
end
self:removeChildrenEvents(child)
self:updateRender()
self.set("childrenSorted", false)
return self
end
--- Locates a child element using a path-like syntax (e.g. "panel/button1")
--- @shortDescription Finds a child element by its path
--- @param path string Path to the child (e.g. "panel/button1", "header/title")
--- @return Element? child The found element or nil if not found
function Container:getChild(path)
if type(path) == "string" then
local parts = split(path, "/")
for _,v in pairs(self._values.children) do
if v.get("name") == parts[1] then
if #parts == 1 then
return v
else
if(v:isType("Container"))then
return v:find(table.concat(parts, "/", 2))
end
end
end
end
end
return nil
end
local function convertMousePosition(self, event, ...)
local args = {...}
if event and event:find("mouse_") then
local button, absX, absY = ...
local xOffset, yOffset = self.getResolved("offsetX"), self.getResolved("offsetY")
local relX, relY = self:getRelativePosition(absX + xOffset, absY + yOffset)
args = {button, relX, relY}
end
return args
end
--- Calls a event on all children
--- @shortDescription Calls a event on all children
--- @param visibleOnly boolean Whether to only call the event on visible children
--- @param event string The event to call
--- @vararg any The event arguments
--- @return boolean handled Whether the event was handled
--- @return table? child The child that handled the event
function Container:callChildrenEvent(visibleOnly, event, ...)
if visibleOnly and not self.getResolved("childrenEventsSorted") then
for evt in pairs(self._values.childrenEvents) do
self:sortChildrenEvents(evt)
end
end
local children = visibleOnly and self.getResolved("visibleChildrenEvents") or self.getResolved("childrenEvents")
if children[event] then
local events = children[event]
for i = #events, 1, -1 do
local child = events[i]
if(child:dispatchEvent(event, ...))then
return true, child
end
end
end
if(children["*"])then
local events = children["*"]
for i = #events, 1, -1 do
local child = events[i]
if(child:dispatchEvent(event, ...))then
return true, child
end
end
end
return false
end
--- @shortDescription Default handler for events
--- @param event string The event to handle
--- @vararg any The event arguments
--- @return boolean handled Whether the event was handled
--- @protected
function Container:handleEvent(event, ...)
VisualElement.handleEvent(self, event, ...)
local args = convertMousePosition(self, event, ...)
return self:callChildrenEvent(false, event, table.unpack(args))
end
--- @shortDescription Handles mouse click events
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local args = convertMousePosition(self, "mouse_click", button, x, y)
local success, child = self:callChildrenEvent(true, "mouse_click", table.unpack(args))
if(success)then
self.set("focusedChild", child)
return true
end
self.set("focusedChild", nil)
return true
end
return false
end
--- @shortDescription Handles mouse up events
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_up(button, x, y)
self:mouse_release(button, x, y)
if VisualElement.mouse_up(self, button, x, y) then
local args = convertMousePosition(self, "mouse_up", button, x, y)
local success, child = self:callChildrenEvent(true, "mouse_up", table.unpack(args))
if(success)then
return true
end
end
return false
end
--- @shortDescription Handles mouse release events
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @protected
function Container:mouse_release(button, x, y)
VisualElement.mouse_release(self, button, x, y)
local args = convertMousePosition(self, "mouse_release", button, x, y)
self:callChildrenEvent(false, "mouse_release", table.unpack(args))
end
--- @shortDescription Handles mouse move events
--- @param _ number unknown
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_move(_, x, y)
if VisualElement.mouse_move(self, _, x, y) then
local args = convertMousePosition(self, "mouse_move", _, x, y)
local success, child = self:callChildrenEvent(true, "mouse_move", table.unpack(args))
if(success)then
return true
end
end
return false
end
--- @shortDescription Handles mouse drag events
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_drag(button, x, y)
if VisualElement.mouse_drag(self, button, x, y) then
local args = convertMousePosition(self, "mouse_drag", button, x, y)
local success, child = self:callChildrenEvent(true, "mouse_drag", table.unpack(args))
if(success)then
return true
end
end
return false
end
--- @shortDescription Handles mouse scroll events
--- @param direction number The direction of the scroll
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_scroll(direction, x, y)
if(VisualElement.mouse_scroll(self, direction, x, y))then
local args = convertMousePosition(self, "mouse_scroll", direction, x, y)
local success, child = self:callChildrenEvent(true, "mouse_scroll", table.unpack(args))
return true
end
return false
end
--- @shortDescription Handles key events
--- @param key number The key that was pressed
--- @return boolean handled Whether the event was handled
--- @protected
function Container:key(key)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("key", key)
end
return true
end
--- @shortDescription Handles char events
--- @param char string The character that was pressed
--- @return boolean handled Whether the event was handled
--- @protected
function Container:char(char)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("char", char)
end
return true
end
--- @shortDescription Handles key up events
--- @param key number The key that was released
--- @return boolean handled Whether the event was handled
--- @protected
function Container:key_up(key)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("key_up", key)
end
return true
end
--- @shortDescription Draws multiple lines of text, fg and bg strings
--- @param x number The x position to draw the text
--- @param y number The y position to draw the text
--- @param width number The width of the text
--- @param height number The height of the text
--- @param text string The text to draw
--- @param fg string The foreground color of the text
--- @param bg string The background color of the text
--- @return Container self The container instance
--- @protected
function Container:multiBlit(x, y, width, height, text, fg, bg)
local w, h = self.getResolved("width"), self.getResolved("height")
width = x < 1 and math.min(width + x - 1, w) or math.min(width, math.max(0, w - x + 1))
height = y < 1 and math.min(height + y - 1, h) or math.min(height, math.max(0, h - y + 1))
if width <= 0 or height <= 0 then return self end
VisualElement.multiBlit(self, math.max(1, x), math.max(1, y), width, height, text, fg, bg)
return self
end
--- @shortDescription Draws a line of text and fg as color
--- @param x number The x position to draw the text
--- @param y number The y position to draw the text
--- @param text string The text to draw
--- @param fg color The foreground color of the text
--- @return Container self The container instance
--- @protected
function Container:textFg(x, y, text, fg)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#text - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
VisualElement.textFg(self, math.max(1, x), math.max(1, y), text:sub(textStart, textStart + textLen - 1), fg)
return self
end
--- @shortDescription Draws a line of text and bg as color
--- @param x number The x position to draw the text
--- @param y number The y position to draw the text
--- @param text string The text to draw
--- @param bg color The background color of the text
--- @return Container self The container instance
--- @protected
function Container:textBg(x, y, text, bg)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#text - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
VisualElement.textBg(self, math.max(1, x), math.max(1, y), text:sub(textStart, textStart + textLen - 1), bg)
return self
end
function Container:drawText(x, y, text)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#text - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
VisualElement.drawText(self, math.max(1, x), math.max(1, y), text:sub(textStart, textStart + textLen - 1))
return self
end
function Container:drawFg(x, y, fg)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#fg - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
VisualElement.drawFg(self, math.max(1, x), math.max(1, y), fg:sub(textStart, textStart + textLen - 1))
return self
end
function Container:drawBg(x, y, bg)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#bg - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
VisualElement.drawBg(self, math.max(1, x), math.max(1, y), bg:sub(textStart, textStart + textLen - 1))
return self
end
--- @shortDescription Draws a line of text and fg and bg as colors
--- @param x number The x position to draw the text
--- @param y number The y position to draw the text
--- @param text string The text to draw
--- @param fg string The foreground color of the text
--- @param bg string The background color of the text
--- @return Container self The container instance
--- @protected
function Container:blit(x, y, text, fg, bg)
local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end
local textStart = x < 1 and (2 - x) or 1
local textLen = math.min(#text - textStart + 1, w - math.max(1, x) + 1)
local fgLen = math.min(#fg - textStart + 1, w - math.max(1, x) + 1)
local bgLen = math.min(#bg - textStart + 1, w - math.max(1, x) + 1)
if textLen <= 0 then return self end
local finalText = text:sub(textStart, textStart + textLen - 1)
local finalFg = fg:sub(textStart, textStart + fgLen - 1)
local finalBg = bg:sub(textStart, textStart + bgLen - 1)
VisualElement.blit(self, math.max(1, x), math.max(1, y), finalText, finalFg, finalBg)
return self
end
--- @shortDescription Renders the container
--- @protected
function Container:render()
VisualElement.render(self)
if not self.getResolved("childrenSorted")then
self:sortChildren()
end
if not self.getResolved("childrenEventsSorted")then
for event in pairs(self._values.childrenEvents) do
self:sortChildrenEvents(event)
end
end
for _, child in ipairs(self.getResolved("visibleChildren")) do
if child == self then
errorManager.error("CIRCULAR REFERENCE DETECTED!")
return
end
child:render()
child:postRender()
end
end
--- Applies a layout from a file to this container
--- @shortDescription Applies a layout to the container
--- @param layoutPath string Path to the layout file (e.g. "layouts/grid")
--- @param options? table Optional layout-specific options
--- @return Container self For method chaining
function Container:applyLayout(layoutPath, options)
if self._layoutInstance then
LayoutManager.destroy(self._layoutInstance)
end
self._layoutInstance = LayoutManager.apply(self, layoutPath)
if options then
self._layoutInstance.options = options
end
return self
end
--- Updates the current layout (recalculates positions)
--- @shortDescription Updates the layout
--- @return Container self For method chaining
function Container:updateLayout()
if self._layoutInstance then
LayoutManager.update(self._layoutInstance)
end
return self
end
--- Removes the current layout
--- @shortDescription Clears the layout
--- @return Container self For method chaining
function Container:clearLayout()
if self._layoutInstance then
local LayoutManager = require("layoutManager")
LayoutManager.destroy(self._layoutInstance)
self._layoutInstance = nil
end
return self
end
--- @private
function Container:destroy()
if not self:isType("BaseFrame") then
for _, child in ipairs(self._values.children) do
if child.destroy then
child:destroy()
end
end
self:removeAllObservers()
VisualElement.destroy(self)
return self
else
errorManager.header = "Basalt Error"
errorManager.error("Cannot destroy a BaseFrame.")
end
end
return Container