629 lines
20 KiB
Lua
629 lines
20 KiB
Lua
local elementManager = require("elementManager")
|
|
local VisualElement = require("elements/VisualElement")
|
|
local Container = elementManager.getElement("Container")
|
|
local tHex = require("libraries/colorHex")
|
|
---@configDescription A TabControl element that provides tabbed interface with multiple content areas.
|
|
---@configDefault false
|
|
|
|
--- The TabControl is a container that provides tabbed interface functionality
|
|
--- @run [[
|
|
--- local basalt = require("basalt")
|
|
---
|
|
--- local main = basalt.getMainFrame()
|
|
---
|
|
--- -- Create a simple TabControl
|
|
--- local tabControl = main:addTabControl({
|
|
--- x = 2,
|
|
--- y = 2,
|
|
--- width = 46,
|
|
--- height = 15,
|
|
--- })
|
|
---
|
|
--- -- Tab 1: Home
|
|
--- local homeTab = tabControl:newTab("Home")
|
|
---
|
|
--- homeTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 2,
|
|
--- text = "Welcome!",
|
|
--- foreground = colors.yellow
|
|
--- })
|
|
---
|
|
--- homeTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 4,
|
|
--- text = "This is a TabControl",
|
|
--- foreground = colors.white
|
|
--- })
|
|
---
|
|
--- homeTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 5,
|
|
--- text = "example with tabs.",
|
|
--- foreground = colors.white
|
|
--- })
|
|
---
|
|
--- -- Tab 2: Counter
|
|
--- local counterTab = tabControl:newTab("Counter")
|
|
---
|
|
--- local counterLabel = counterTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 2,
|
|
--- text = "Count: 0",
|
|
--- foreground = colors.lime
|
|
--- })
|
|
---
|
|
--- local count = 0
|
|
--- counterTab:addButton({
|
|
--- x = 2,
|
|
--- y = 4,
|
|
--- width = 12,
|
|
--- height = 3,
|
|
--- text = "Click Me",
|
|
--- background = colors.blue
|
|
--- })
|
|
--- :setBackgroundState("clicked", colors.lightBlue)
|
|
--- :onClick(function()
|
|
--- count = count + 1
|
|
--- counterLabel:setText("Count: " .. count)
|
|
--- end)
|
|
---
|
|
--- -- Tab 3: Info
|
|
--- local infoTab = tabControl:newTab("Info")
|
|
---
|
|
--- infoTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 2,
|
|
--- text = "TabControl Features:",
|
|
--- foreground = colors.orange
|
|
--- })
|
|
---
|
|
--- infoTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 4,
|
|
--- text = "- Horizontal tabs",
|
|
--- foreground = colors.gray
|
|
--- })
|
|
---
|
|
--- infoTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 5,
|
|
--- text = "- Easy navigation",
|
|
--- foreground = colors.gray
|
|
--- })
|
|
---
|
|
--- infoTab:addLabel({
|
|
--- x = 2,
|
|
--- y = 6,
|
|
--- text = "- Content per tab",
|
|
--- foreground = colors.gray
|
|
--- })
|
|
---
|
|
--- basalt.run()
|
|
--- ]]
|
|
---@class TabControl : Container
|
|
local TabControl = setmetatable({}, Container)
|
|
TabControl.__index = TabControl
|
|
|
|
---@property activeTab number nil The currently active tab ID
|
|
TabControl.defineProperty(TabControl, "activeTab", {default = nil, type = "number", allowNil = true, canTriggerRender = true, setter = function(self, value)
|
|
return value
|
|
end})
|
|
---@property tabHeight number 1 Height of the tab header area
|
|
TabControl.defineProperty(TabControl, "tabHeight", {default = 1, type = "number", canTriggerRender = true})
|
|
---@property tabs table {} List of tab definitions
|
|
TabControl.defineProperty(TabControl, "tabs", {default = {}, type = "table"})
|
|
|
|
---@property headerBackground color gray Background color for the tab header area
|
|
TabControl.defineProperty(TabControl, "headerBackground", {default = colors.gray, type = "color", canTriggerRender = true})
|
|
---@property activeTabBackground color white Background color for the active tab
|
|
TabControl.defineProperty(TabControl, "activeTabBackground", {default = colors.white, type = "color", canTriggerRender = true})
|
|
---@property activeTabTextColor color black Foreground color for the active tab text
|
|
TabControl.defineProperty(TabControl, "activeTabTextColor", {default = colors.black, type = "color", canTriggerRender = true})
|
|
---@property scrollableTab boolean false Enables scroll mode for tabs if they exceed width
|
|
TabControl.defineProperty(TabControl, "scrollableTab", {default = false, type = "boolean", canTriggerRender = true})
|
|
---@property tabScrollOffset number 0 Current scroll offset for tabs in scrollable mode
|
|
TabControl.defineProperty(TabControl, "tabScrollOffset", {default = 0, type = "number", canTriggerRender = true})
|
|
|
|
TabControl.defineEvent(TabControl, "mouse_click")
|
|
TabControl.defineEvent(TabControl, "mouse_up")
|
|
TabControl.defineEvent(TabControl, "mouse_scroll")
|
|
|
|
--- @shortDescription Creates a new TabControl instance
|
|
--- @return TabControl self The created instance
|
|
--- @private
|
|
function TabControl.new()
|
|
local self = setmetatable({}, TabControl):__init()
|
|
self.class = TabControl
|
|
self.set("width", 20)
|
|
self.set("height", 10)
|
|
self.set("z", 10)
|
|
return self
|
|
end
|
|
|
|
--- @shortDescription Initializes the TabControl instance
|
|
--- @param props table The properties to initialize the element with
|
|
--- @param basalt table The basalt instance
|
|
--- @protected
|
|
function TabControl:init(props, basalt)
|
|
Container.init(self, props, basalt)
|
|
self.set("type", "TabControl")
|
|
end
|
|
|
|
--- returns a proxy for adding elements to the tab
|
|
--- @shortDescription Creates a new tab handler proxy
|
|
--- @param title string The title of the tab
|
|
--- @return table tabHandler The tab handler proxy for adding elements to the new tab
|
|
function TabControl:newTab(title)
|
|
local tabs = self.get("tabs") or {}
|
|
local tabId = #tabs + 1
|
|
|
|
table.insert(tabs, {
|
|
id = tabId,
|
|
title = tostring(title or ("Tab " .. tabId))
|
|
})
|
|
|
|
self.set("tabs", tabs)
|
|
|
|
if not self.get("activeTab") then
|
|
self.set("activeTab", tabId)
|
|
end
|
|
self:updateTabVisibility()
|
|
|
|
local tabControl = self
|
|
local proxy = {}
|
|
setmetatable(proxy, {
|
|
__index = function(_, key)
|
|
if type(key) == "string" and key:sub(1,3) == "add" and type(tabControl[key]) == "function" then
|
|
return function(_, ...)
|
|
local el = tabControl[key](tabControl, ...)
|
|
if el then
|
|
el._tabId = tabId
|
|
tabControl.set("childrenSorted", false)
|
|
tabControl.set("childrenEventsSorted", false)
|
|
tabControl:updateRender()
|
|
end
|
|
return el
|
|
end
|
|
end
|
|
local v = tabControl[key]
|
|
if type(v) == "function" then
|
|
return function(_, ...)
|
|
return v(tabControl, ...)
|
|
end
|
|
end
|
|
return v
|
|
end
|
|
})
|
|
|
|
return proxy
|
|
end
|
|
TabControl.addTab = TabControl.newTab
|
|
|
|
--- @shortDescription Sets an element to belong to a specific tab
|
|
--- @param element table The element to assign to a tab
|
|
--- @param tabId number The ID of the tab to assign the element to
|
|
--- @return TabControl self For method chaining
|
|
function TabControl:setTab(element, tabId)
|
|
element._tabId = tabId
|
|
self:updateTabVisibility()
|
|
return self
|
|
end
|
|
|
|
--- @shortDescription Adds an element to the TabControl and assigns it to the active tab
|
|
--- @param elementType string The type of element to add
|
|
--- @param tabId number Optional tab ID, defaults to active tab
|
|
--- @return table element The created element
|
|
function TabControl:addElement(elementType, tabId)
|
|
local element = Container.addElement(self, elementType)
|
|
local targetTab = tabId or self.get("activeTab")
|
|
if targetTab then
|
|
element._tabId = targetTab
|
|
self:updateTabVisibility()
|
|
end
|
|
return element
|
|
end
|
|
|
|
--- @shortDescription Overrides Container's addChild to assign new elements to tab 1 by default
|
|
--- @param child table The child element to add
|
|
--- @return Container self For method chaining
|
|
--- @protected
|
|
function TabControl:addChild(child)
|
|
Container.addChild(self, child)
|
|
if not child._tabId then
|
|
local tabs = self.get("tabs") or {}
|
|
if #tabs > 0 then
|
|
child._tabId = 1
|
|
self:updateTabVisibility()
|
|
end
|
|
end
|
|
return self
|
|
end
|
|
|
|
--- @shortDescription Updates visibility of tab containers
|
|
--- @private
|
|
function TabControl:updateTabVisibility()
|
|
self.set("childrenSorted", false)
|
|
self.set("childrenEventsSorted", false)
|
|
end
|
|
|
|
--- @shortDescription Sets the active tab
|
|
--- @param tabId number The ID of the tab to activate
|
|
function TabControl:setActiveTab(tabId)
|
|
local oldTab = self.get("activeTab")
|
|
if oldTab == tabId then return self end
|
|
self.set("activeTab", tabId)
|
|
self:updateTabVisibility()
|
|
self:dispatchEvent("tabChanged", tabId, oldTab)
|
|
return self
|
|
end
|
|
|
|
--- @shortDescription Checks if a child should be visible (overrides Container)
|
|
--- @param child table The child element to check
|
|
--- @return boolean Whether the child should be visible
|
|
--- @protected
|
|
function TabControl:isChildVisible(child)
|
|
if not Container.isChildVisible(self, child) then
|
|
return false
|
|
end
|
|
if child._tabId then
|
|
return child._tabId == self.get("activeTab")
|
|
end
|
|
return true
|
|
end
|
|
|
|
--- @shortDescription Gets the content area Y offset (below tab headers)
|
|
--- @return number yOffset The Y offset for content
|
|
--- @protected
|
|
function TabControl:getContentYOffset()
|
|
local metrics = self:_getHeaderMetrics()
|
|
return metrics.headerHeight
|
|
end
|
|
|
|
function TabControl:_getHeaderMetrics()
|
|
local tabs = self.get("tabs") or {}
|
|
local width = self.get("width") or 1
|
|
local minTabH = self.get("tabHeight") or 1
|
|
local scrollable = self.get("scrollableTab")
|
|
|
|
local positions = {}
|
|
|
|
if scrollable then
|
|
local scrollOffset = self.get("tabScrollOffset") or 0
|
|
local actualX = 1
|
|
local totalWidth = 0
|
|
|
|
for i, tab in ipairs(tabs) do
|
|
local tabWidth = #tab.title + 2
|
|
if tabWidth > width then
|
|
tabWidth = width
|
|
end
|
|
|
|
local visualX = actualX - scrollOffset
|
|
local startClip = 0
|
|
local endClip = 0
|
|
|
|
if visualX < 1 then
|
|
startClip = 1 - visualX
|
|
end
|
|
|
|
if visualX + tabWidth - 1 > width then
|
|
endClip = (visualX + tabWidth - 1) - width
|
|
end
|
|
|
|
if visualX + tabWidth > 1 and visualX <= width then
|
|
local displayX = math.max(1, visualX)
|
|
local displayWidth = tabWidth - startClip - endClip
|
|
|
|
table.insert(positions, {
|
|
id = tab.id,
|
|
title = tab.title,
|
|
line = 1,
|
|
x1 = displayX,
|
|
x2 = displayX + displayWidth - 1,
|
|
width = tabWidth,
|
|
displayWidth = displayWidth,
|
|
actualX = actualX,
|
|
startClip = startClip,
|
|
endClip = endClip
|
|
})
|
|
end
|
|
|
|
actualX = actualX + tabWidth
|
|
end
|
|
|
|
totalWidth = actualX - 1
|
|
|
|
return {
|
|
headerHeight = 1,
|
|
lines = 1,
|
|
positions = positions,
|
|
totalWidth = totalWidth,
|
|
scrollOffset = scrollOffset,
|
|
maxScroll = math.max(0, totalWidth - width)
|
|
}
|
|
else
|
|
local line = 1
|
|
local cursorX = 1
|
|
|
|
for i, tab in ipairs(tabs) do
|
|
local tabWidth = #tab.title + 2
|
|
if tabWidth > width then
|
|
tabWidth = width
|
|
end
|
|
if cursorX + tabWidth - 1 > width then
|
|
line = line + 1
|
|
cursorX = 1
|
|
end
|
|
table.insert(positions, {
|
|
id = tab.id,
|
|
title = tab.title,
|
|
line = line,
|
|
x1 = cursorX,
|
|
x2 = cursorX + tabWidth - 1,
|
|
width = tabWidth
|
|
})
|
|
cursorX = cursorX + tabWidth
|
|
end
|
|
|
|
local computedLines = line
|
|
local headerHeight = math.max(minTabH, computedLines)
|
|
return {headerHeight = headerHeight, lines = computedLines, positions = positions}
|
|
end
|
|
end
|
|
|
|
|
|
--- @shortDescription Handles mouse click events for tab switching
|
|
--- @param button number The button that was clicked
|
|
--- @param x number The x position of the click (global)
|
|
--- @param y number The y position of the click (global)
|
|
--- @return boolean Whether the event was handled
|
|
--- @protected
|
|
function TabControl:mouse_click(button, x, y)
|
|
if not VisualElement.mouse_click(self, button, x, y) then
|
|
return false
|
|
end
|
|
|
|
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
|
|
local metrics = self:_getHeaderMetrics()
|
|
if baseRelY <= metrics.headerHeight then
|
|
if #metrics.positions == 0 then return true end
|
|
for _, pos in ipairs(metrics.positions) do
|
|
if pos.line == baseRelY and baseRelX >= pos.x1 and baseRelX <= pos.x2 then
|
|
self:setActiveTab(pos.id)
|
|
self.set("focusedChild", nil)
|
|
return true
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
return Container.mouse_click(self, button, x, y)
|
|
end
|
|
|
|
function TabControl:getRelativePosition(x, y)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
if x == nil or y == nil then
|
|
return VisualElement.getRelativePosition(self)
|
|
else
|
|
local rx, ry = VisualElement.getRelativePosition(self, x, y)
|
|
return rx, ry - headerH
|
|
end
|
|
end
|
|
|
|
function TabControl:multiBlit(x, y, width, height, text, fg, bg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.multiBlit(self, x, (y or 1) + headerH, width, height, text, fg, bg)
|
|
end
|
|
|
|
function TabControl:textFg(x, y, text, fg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.textFg(self, x, (y or 1) + headerH, text, fg)
|
|
end
|
|
|
|
function TabControl:textBg(x, y, text, bg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.textBg(self, x, (y or 1) + headerH, text, bg)
|
|
end
|
|
|
|
function TabControl:drawText(x, y, text)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.drawText(self, x, (y or 1) + headerH, text)
|
|
end
|
|
|
|
function TabControl:drawFg(x, y, fg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.drawFg(self, x, (y or 1) + headerH, fg)
|
|
end
|
|
|
|
function TabControl:drawBg(x, y, bg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.drawBg(self, x, (y or 1) + headerH, bg)
|
|
end
|
|
|
|
function TabControl:blit(x, y, text, fg, bg)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
return Container.blit(self, x, (y or 1) + headerH, text, fg, bg)
|
|
end
|
|
|
|
function TabControl:mouse_up(button, x, y)
|
|
if not VisualElement.mouse_up(self, button, x, y) then
|
|
return false
|
|
end
|
|
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
if baseRelY <= headerH then
|
|
return true
|
|
end
|
|
return Container.mouse_up(self, button, x, y)
|
|
end
|
|
|
|
function TabControl:mouse_release(button, x, y)
|
|
VisualElement.mouse_release(self, button, x, y)
|
|
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
if baseRelY <= headerH then
|
|
return
|
|
end
|
|
return Container.mouse_release(self, button, x, y)
|
|
end
|
|
|
|
function TabControl:mouse_move(_, x, y)
|
|
if VisualElement.mouse_move(self, _, x, y) then
|
|
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
if baseRelY <= headerH then
|
|
return true
|
|
end
|
|
local args = {self:getRelativePosition(x, y)}
|
|
local success, child = self:callChildrenEvent(true, "mouse_move", table.unpack(args))
|
|
if success then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function TabControl:mouse_drag(button, x, y)
|
|
if VisualElement.mouse_drag(self, button, x, y) then
|
|
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
if baseRelY <= headerH then
|
|
return true
|
|
end
|
|
return Container.mouse_drag(self, button, x, y)
|
|
end
|
|
return false
|
|
end
|
|
|
|
---Scrolls the tab header left or right if scrollableTab is enabled
|
|
--- @shortDescription Scrolls the tab header left or right if scrollableTab is enabled
|
|
--- @param direction number -1 to scroll left, 1 to scroll right
|
|
--- @return TabControl self For method chaining
|
|
function TabControl:scrollTabs(direction)
|
|
if not self.get("scrollableTab") then return self end
|
|
|
|
local metrics = self:_getHeaderMetrics()
|
|
local currentOffset = self.get("tabScrollOffset") or 0
|
|
local maxScroll = metrics.maxScroll or 0
|
|
|
|
local newOffset = currentOffset + (direction * 5)
|
|
newOffset = math.max(0, math.min(maxScroll, newOffset))
|
|
|
|
self.set("tabScrollOffset", newOffset)
|
|
return self
|
|
end
|
|
|
|
function TabControl:mouse_scroll(direction, x, y)
|
|
if VisualElement.mouse_scroll(self, direction, x, y) then
|
|
local headerH = self:_getHeaderMetrics().headerHeight
|
|
|
|
if self.get("scrollableTab") and y == self.get("y") then
|
|
self:scrollTabs(direction)
|
|
return true
|
|
end
|
|
|
|
return Container.mouse_scroll(self, direction, x, y)
|
|
end
|
|
return false
|
|
end
|
|
|
|
|
|
--- @shortDescription Sets the cursor position; accounts for tab header offset when delegating to parent
|
|
function TabControl:setCursor(x, y, blink, color)
|
|
local tabH = self:_getHeaderMetrics().headerHeight
|
|
if self.parent then
|
|
local xPos, yPos = self:calculatePosition()
|
|
local targetX = x + xPos - 1
|
|
local targetY = y + yPos - 1 + tabH
|
|
|
|
if(targetX < 1) or (targetX > self.parent.get("width")) or
|
|
(targetY < 1) or (targetY > self.parent.get("height")) then
|
|
return self.parent:setCursor(targetX, targetY, false)
|
|
end
|
|
return self.parent:setCursor(targetX, targetY, blink, color)
|
|
end
|
|
return self
|
|
end
|
|
|
|
--- @shortDescription Renders the TabControl (header + children)
|
|
--- @protected
|
|
function TabControl:render()
|
|
VisualElement.render(self)
|
|
local width = self.get("width")
|
|
local metrics = self:_getHeaderMetrics()
|
|
local headerH = metrics.headerHeight or 1
|
|
|
|
VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[self.get("foreground")], tHex[self.get("headerBackground")])
|
|
local activeTab = self.get("activeTab")
|
|
|
|
for _, pos in ipairs(metrics.positions) do
|
|
local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("headerBackground")
|
|
local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground")
|
|
|
|
VisualElement.multiBlit(self, pos.x1, pos.line, pos.displayWidth or (pos.x2 - pos.x1 + 1), 1, " ", tHex[self.get("foreground")], tHex[bgColor])
|
|
|
|
local displayTitle = pos.title
|
|
local textStartInTitle = 1 + (pos.startClip or 0)
|
|
local textLength = #pos.title - (pos.startClip or 0) - (pos.endClip or 0)
|
|
|
|
if textLength > 0 then
|
|
displayTitle = pos.title:sub(textStartInTitle, textStartInTitle + textLength - 1)
|
|
local textX = pos.x1
|
|
if (pos.startClip or 0) == 0 then
|
|
textX = textX + 1
|
|
end
|
|
VisualElement.textFg(self, textX, pos.line, displayTitle, fgColor)
|
|
end
|
|
end
|
|
|
|
if not self.get("childrenSorted") then
|
|
self:sortChildren()
|
|
end
|
|
if not self.get("childrenEventsSorted") then
|
|
for eventName in pairs(self._values.childrenEvents or {}) do
|
|
self:sortChildrenEvents(eventName)
|
|
end
|
|
end
|
|
|
|
for _, child in ipairs(self.get("visibleChildren") or {}) do
|
|
if child == self then error("CIRCULAR REFERENCE DETECTED!") return end
|
|
child:render()
|
|
child:postRender()
|
|
end
|
|
end
|
|
|
|
--- @protected
|
|
function TabControl:sortChildrenEvents(eventName)
|
|
local childrenEvents = self._values.childrenEvents and self._values.childrenEvents[eventName]
|
|
if childrenEvents then
|
|
local visibleChildrenEvents = {}
|
|
for _, child in ipairs(childrenEvents) do
|
|
if self:isChildVisible(child) then
|
|
table.insert(visibleChildrenEvents, child)
|
|
end
|
|
end
|
|
|
|
for i = 2, #visibleChildrenEvents do
|
|
local current = visibleChildrenEvents[i]
|
|
local currentZ = current.get("z")
|
|
local j = i - 1
|
|
while j > 0 do
|
|
local compare = visibleChildrenEvents[j].get("z")
|
|
if compare > currentZ then
|
|
visibleChildrenEvents[j + 1] = visibleChildrenEvents[j]
|
|
j = j - 1
|
|
else
|
|
break
|
|
end
|
|
end
|
|
visibleChildrenEvents[j + 1] = current
|
|
end
|
|
|
|
self._values.visibleChildrenEvents = self._values.visibleChildrenEvents or {}
|
|
self._values.visibleChildrenEvents[eventName] = visibleChildrenEvents
|
|
end
|
|
self.set("childrenEventsSorted", true)
|
|
return self
|
|
end
|
|
|
|
return TabControl |