- Updated DocsGenerator to support run code and item tables

- Updated Table to support new Collection System (could break things, sorry)
- Updated Tree to support new Collection System
- Added experimental ScrollFrame
- Updated Menu to support Collection System
This commit is contained in:
Robert Jelic
2025-10-29 17:55:29 +01:00
parent 41bd5bdf04
commit 6f14eadf0a
12 changed files with 1264 additions and 265 deletions

3
.gitignore vendored
View File

@@ -17,4 +17,5 @@ Drawer.lua
Breadcrumb.lua
Dialog.lua
DockLayout.lua
ContextMenu.lua
ContextMenu.lua
Toast.lua

View File

@@ -5,51 +5,50 @@ local tHex = require("libraries/colorHex")
---@configDescription A DropDown menu that shows a list of selectable items
---@configDefault false
--- Item Properties:
--- Property|Type|Description
--- -------|------|-------------
--- text|string|The display text for the item
--- separator|boolean|Makes item a divider line
--- callback|function|Function called when selected
--- foreground|color|Normal text color
--- background|color|Normal background color
--- selectedForeground|color|Text color when selected
--- selectedBackground|color|Background when selected
---@tableType ItemTable
---@tableField text string The display text for the item
---@tableField callback function Function called when selected
---@tableField fg color Normal text color
---@tableField bg color Normal background color
---@tableField selectedFg color Text color when selected
---@tableField selectedBg color Background when selected
--- A collapsible selection menu that expands to show multiple options when clicked. Supports single and multi-selection modes, custom item styling, separators, and item callbacks.
--- @usage -- Create a styled dropdown menu
--- @usage local dropdown = main:addDropDown()
--- @usage :setPosition(5, 5)
--- @usage :setSize(20, 1) -- Height expands when opened
--- @usage :setSelectedText("Select an option...")
--- @usage
--- @usage -- Add items with different styles and callbacks
--- @usage dropdown:setItems({
--- @usage {
--- @usage text = "Category A",
--- @usage background = colors.blue,
--- @usage foreground = colors.white
--- @usage },
--- @usage { separator = true, text = "-" }, -- Add a separator
--- @usage {
--- @usage text = "Option 1",
--- @usage callback = function(self)
--- @usage -- Handle selection
--- @usage basalt.debug("Selected Option 1")
--- @usage end
--- @usage },
--- @usage {
--- @usage text = "Option 2",
--- @usage -- Custom colors when selected
--- @usage selectedBackground = colors.green,
--- @usage selectedForeground = colors.white
--- @usage }
--- @usage })
--- @usage
--- @usage -- Listen for selections
--- @usage dropdown:onChange(function(self, value)
--- @usage basalt.debug("Selected:", value)
--- @usage end)
--- @usage [[
--- -- Create a styled dropdown menu
--- local dropdown = main:addDropDown()
--- :setPosition(5, 5)
--- :setSize(20, 1) -- Height expands when opened
--- :setSelectedText("Select an option...")
---
--- -- Add items with different styles and callbacks
--- dropdown:setItems({
--- {
--- text = "Category A",
--- background = colors.blue,
--- foreground = colors.white
--- },
--- { separator = true, text = "-" }, -- Add a separator
--- {
--- text = "Option 1",
--- callback = function(self)
--- -- Handle selection
--- basalt.debug("Selected Option 1")
--- end
--- },
--- {
--- text = "Option 2",
--- -- Custom colors when selected
--- selectedBackground = colors.green,
--- selectedForeground = colors.white
--- }
--- })
---
--- -- Listen for selections
--- dropdown:onChange(function(self, value)
--- basalt.debug("Selected:", value)
--- end)
--- ]]
---@class DropDown : List
local DropDown = setmetatable({}, List)
DropDown.__index = DropDown
@@ -107,7 +106,6 @@ function DropDown:mouse_click(button, x, y)
end
return true
elseif isOpen and relY > 1 then
-- Forward to List handler for scrollbar handling
return List.mouse_click(self, button, x, y - 1)
end
return false
@@ -135,19 +133,18 @@ end
function DropDown:mouse_up(button, x, y)
if self:hasState("opened") then
local relX, relY = self:getRelativePosition(x, y)
-- Only handle item selection in mouse_up (relY > 1 = list area)
if relY > 1 and self.get("selectable") and not self._scrollBarDragging then
local itemIndex = (relY - 1) + self.get("offset")
local items = self.get("items")
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
@@ -155,24 +152,23 @@ function DropDown:mouse_up(button, x, y)
end
end
end
item.selected = not item.selected
if item.callback then
item.callback(self)
end
self:fireEvent("select", itemIndex, item)
self:unsetState("opened")
self:unsetState("clicked")
self.set("height", 1)
self:updateRender()
return true
end
end
-- Always forward to List for cleanup and unset clicked state
List.mouse_up(self, button, x, y - 1)
self:unsetState("clicked")
return true

View File

@@ -44,6 +44,14 @@ List.defineEvent(List, "mouse_drag")
List.defineEvent(List, "mouse_scroll")
List.defineEvent(List, "key")
---@tableType ItemTable
---@tableField text string The display text for the item
---@tableField callback function Function called when selected
---@tableField fg color Normal text color
---@tableField bg color Normal background color
---@tableField selectedFg color Text color when selected
---@tableField selectedBg color Background when selected
local entrySchema = {
text = { type = "string", default = "Entry" },
bg = { type = "number", default = nil },
@@ -265,7 +273,7 @@ function List:key(keyCode)
if Collection.key(self, keyCode) and self.get("selectable") then
local items = self.get("items")
local currentIndex = self:getSelectedIndex()
if keyCode == keys.up then
self:selectPrevious()
if currentIndex and currentIndex > 1 then

View File

@@ -3,15 +3,39 @@ local List = require("elements/List")
local tHex = require("libraries/colorHex")
---@configDescription A horizontal menu bar with selectable items.
--- This is the menu class. It provides a horizontal menu bar with selectable items.
--- Menu items are displayed in a single row and can have custom colors and callbacks.
--- This is the menu class. It provides a horizontal menu bar with selectable items. Menu items are displayed in a single row and can have custom colors and callbacks.
---@class Menu : List
local Menu = setmetatable({}, List)
Menu.__index = Menu
---@tableType ItemTable
---@tableField text string The display text for the item
---@tableField callback function Function called when selected
---@tableField fg color Normal text color
---@tableField bg color Normal background color
---@tableField selectedFg color Text color when selected
---@tableField selectedBg color Background when selected
---@property separatorColor color gray The color used for separator items in the menu
Menu.defineProperty(Menu, "separatorColor", {default = colors.gray, type = "color"})
---@property spacing number 0 The number of spaces between menu items
Menu.defineProperty(Menu, "spacing", {default = 1, type = "number", canTriggerRender = true})
---@property horizontalOffset number 0 Current horizontal scroll offset
Menu.defineProperty(Menu, "horizontalOffset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
local maxOffset = math.max(0, self:getTotalWidth() - self.get("width"))
return math.min(maxOffset, math.max(0, value))
end
})
---@property maxWidth number nil Maximum width before scrolling is enabled (nil = auto-size to items)
Menu.defineProperty(Menu, "maxWidth", {default = nil, type = "number", canTriggerRender = true})
--- Creates a new Menu instance
--- @shortDescription Creates a new Menu instance
--- @return Menu self The newly created Menu instance
@@ -33,83 +57,151 @@ end
function Menu:init(props, basalt)
List.init(self, props, basalt)
self.set("type", "Menu")
self:observe("items", function()
local maxWidth = self.get("maxWidth")
if maxWidth then
self.set("width", math.min(maxWidth, self:getTotalWidth()), true)
else
self.set("width", self:getTotalWidth(), true)
end
end)
return self
end
--- Sets the menu items
--- @shortDescription Sets the menu items and calculates total width
--- @param items table[] List of items with {text, separator, callback, foreground, background} properties
--- @return Menu self The Menu instance
--- @usage menu:setItems({{text="File"}, {separator=true}, {text="Edit"}})
function Menu:setItems(items)
local listItems = {}
--- Calculates the total width of all menu items with spacing
--- @shortDescription Calculates total width of menu items
--- @return number totalWidth The total width of all items
function Menu:getTotalWidth()
local items = self.get("items")
local spacing = self.get("spacing")
local totalWidth = 0
for _, item in ipairs(items) do
if item.separator then
table.insert(listItems, {text = item.text or "|", selectable = false})
totalWidth = totalWidth + 1
for i, item in ipairs(items) do
if type(item) == "table" then
totalWidth = totalWidth + #item.text
else
local text = " " .. item.text .. " "
item.text = text
table.insert(listItems, item)
totalWidth = totalWidth + #text
totalWidth = totalWidth + #tostring(item) + 2
end
if i < #items then
totalWidth = totalWidth + spacing
end
end
self.set("width", totalWidth)
return List.setItems(self, listItems)
return totalWidth
end
--- @shortDescription Renders the menu horizontally with proper spacing and colors
--- @protected
function Menu:render()
VisualElement.render(self)
local viewportWidth = self.get("width")
local spacing = self.get("spacing")
local offset = self.get("horizontalOffset")
local items = self.get("items")
local itemPositions = {}
local currentX = 1
for i, item in ipairs(self.get("items")) do
for i, item in ipairs(items) do
if type(item) == "string" then
item = {text = " "..item.." "}
self.get("items")[i] = item
items[i] = item
end
local isSelected = item.selected
local fg = item.selectable == false and self.get("separatorColor") or
(isSelected and (item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground")))
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
self:blit(currentX, 1, item.text,
string.rep(tHex[fg], #item.text),
string.rep(tHex[bg], #item.text))
itemPositions[i] = {
startX = currentX,
endX = currentX + #item.text - 1,
text = item.text,
item = item
}
currentX = currentX + #item.text
if i < #items and spacing > 0 then
currentX = currentX + spacing
end
end
for i, pos in ipairs(itemPositions) do
local item = pos.item
local itemStartInViewport = pos.startX - offset
local itemEndInViewport = pos.endX - offset
if itemStartInViewport > viewportWidth then
break
end
if itemEndInViewport >= 1 then
local visibleStart = math.max(1, itemStartInViewport)
local visibleEnd = math.min(viewportWidth, itemEndInViewport)
local textStartIdx = math.max(1, 1 - itemStartInViewport + 1)
local textEndIdx = math.min(#pos.text, #pos.text - (itemEndInViewport - viewportWidth))
local visibleText = pos.text:sub(textStartIdx, textEndIdx)
if #visibleText > 0 then
local isSelected = item.selected
local fg = item.selectable == false and self.get("separatorColor") or
(isSelected and (item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground")))
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
self:blit(visibleStart, 1, visibleText,
string.rep(tHex[fg], #visibleText),
string.rep(tHex[bg], #visibleText))
end
if i < #items and spacing > 0 then
local spacingStart = pos.endX + 1 - offset
local spacingEnd = spacingStart + spacing - 1
if spacingEnd >= 1 and spacingStart <= viewportWidth then
local visibleSpacingStart = math.max(1, spacingStart)
local visibleSpacingEnd = math.min(viewportWidth, spacingEnd)
local spacingWidth = visibleSpacingEnd - visibleSpacingStart + 1
if spacingWidth > 0 then
local spacingText = string.rep(" ", spacingWidth)
self:blit(visibleSpacingStart, 1, spacingText,
string.rep(tHex[self.get("foreground")], spacingWidth),
string.rep(tHex[self.get("background")], spacingWidth))
end
end
end
end
end
end
--- @shortDescription Handles mouse click events and item selection
--- @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 Whether the event was handled
--- @protected
function Menu:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end
if(self.get("selectable") == false) then return false end
local relX = select(1, self:getRelativePosition(x, y))
local offset = self.get("horizontalOffset")
local spacing = self.get("spacing")
local items = self.get("items")
local virtualX = relX + offset
local currentX = 1
for i, item in ipairs(self.get("items")) do
if relX >= currentX and relX < currentX + #item.text then
for i, item in ipairs(items) do
local itemWidth = #item.text
if virtualX >= currentX and virtualX < currentX + itemWidth then
if item.selectable ~= false then
if type(item) == "string" then
item = {text = item}
self.get("items")[i] = item
items[i] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(self.get("items")) do
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
@@ -125,7 +217,25 @@ function Menu:mouse_click(button, x, y)
end
return true
end
currentX = currentX + #item.text
currentX = currentX + itemWidth
if i < #items and spacing > 0 then
currentX = currentX + spacing
end
end
return false
end
--- @shortDescription Handles mouse scroll events for horizontal scrolling
--- @protected
function Menu:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local offset = self.get("horizontalOffset")
local maxOffset = math.max(0, self:getTotalWidth() - self.get("width"))
offset = math.min(maxOffset, math.max(0, offset + (direction * 3)))
self.set("horizontalOffset", offset)
return true
end
return false
end

View File

@@ -0,0 +1,330 @@
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.
--- A container that provides automatic scrolling capabilities with visual scrollbars. Displays vertical and/or horizontal scrollbars when child content exceeds the container's dimensions.
---@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 scrollBarBackground string "\127" The symbol used for the scrollbar background
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackground", {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 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.get("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.get("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.get("width")
local height = self.get("height")
local showScrollBar = self.get("showScrollBar")
local contentWidth = self.get("contentWidth")
local contentHeight = self.get("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.get("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.get("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.get("height")
local contentWidth = self.get("contentWidth")
local contentHeight = self.get("contentHeight")
local width = self.get("width")
local needsHorizontalScrollBar = self.get("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.get("width")
local contentWidth = self.get("contentWidth")
local contentHeight = self.get("contentHeight")
local height = self.get("height")
local needsHorizontalScrollBar = self.get("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.get("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)
local height = self.get("height")
local width = self.get("width")
local offsetY = self.get("offsetY")
local contentWidth = self.get("contentWidth")
local contentHeight = self.get("contentHeight")
local needsHorizontalScrollBar = self.get("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local maxScroll = math.max(0, contentHeight - viewportHeight)
local newScroll = math.min(maxScroll, math.max(0, offsetY + direction))
self.set("offsetY", newScroll)
return true
end
--- Renders the ScrollFrame and its scrollbars
--- @shortDescription Renders the ScrollFrame and its scrollbars
--- @protected
function ScrollFrame:render()
Container.render(self)
local height = self.get("height")
local width = self.get("width")
local offsetY = self.get("offsetY")
local offsetX = self.get("offsetX")
local showScrollBar = self.get("showScrollBar")
local contentWidth = self.get("contentWidth")
local contentHeight = self.get("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 scrollBarSymbol = self.get("scrollBarSymbol")
local scrollBarBg = self.get("scrollBarBackground")
local scrollBarColor = self.get("scrollBarColor")
local scrollBarBgColor = self.get("scrollBarBackgroundColor")
local foreground = self.get("foreground")
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, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
else
self:blit(width, i, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
end
end
if needsHorizontalScrollBar then
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
local scrollBarSymbol = self.get("scrollBarSymbol")
local scrollBarBg = self.get("scrollBarBackground")
local scrollBarColor = self.get("scrollBarColor")
local scrollBarBgColor = self.get("scrollBarBackgroundColor")
local foreground = self.get("foreground")
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, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
else
self:blit(i, height, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
end
end
if needsVerticalScrollBar and needsHorizontalScrollBar then
local background = self.get("background")
self:blit(width, height, " ", tHex[background], tHex[background])
end
end
return ScrollFrame

View File

@@ -1,13 +1,12 @@
local VisualElement = require("elements/VisualElement")
local Collection = require("elements/Collection")
local tHex = require("libraries/colorHex")
--- This is the table class. It provides a sortable data grid with customizable columns,
--- row selection, and scrolling capabilities.
--- This is the table class. It provides a sortable data grid with customizable columns, row selection, and scrolling capabilities. Built on Collection for consistent item management.
--- @usage local people = container:addTable():setWidth(40)
--- @usage people:setColumns({{name="Name",width=12}, {name="Age",width=10}, {name="Country",width=15}})
--- @usage people:setData({{"Alice", 30, "USA"}, {"Bob", 25, "UK"}})
---@class Table : VisualElement
local Table = setmetatable({}, VisualElement)
--- @usage people:addRow("Alice", 30, "USA"):addRow("Bob", 25, "UK")
---@class Table : Collection
local Table = setmetatable({}, Collection)
Table.__index = Table
---@property columns table {} List of column definitions with {name, width} properties
@@ -27,34 +26,55 @@ Table.defineProperty(Table, "columns", {default = {}, type = "table", canTrigger
end
return t
end})
---@property data table {} The table data as array of row arrays
Table.defineProperty(Table, "data", {default = {}, type = "table", canTriggerRender = true, setter=function(self, value)
self.set("scrollOffset", 0)
self.set("selectedRow", nil)
self.set("sortColumn", nil)
self.set("sortDirection", "asc")
return value
end})
---@property selectedRow number? nil Currently selected row index
Table.defineProperty(Table, "selectedRow", {default = nil, type = "number", canTriggerRender = true})
---@property headerColor color blue Color of the column headers
Table.defineProperty(Table, "headerColor", {default = colors.blue, type = "color"})
---@property selectedColor color lightBlue Background color of selected row
Table.defineProperty(Table, "selectedColor", {default = colors.lightBlue, type = "color"})
---@property gridColor color gray Color of grid lines
Table.defineProperty(Table, "gridColor", {default = colors.gray, type = "color"})
---@property sortColumn number? nil Currently sorted column index
Table.defineProperty(Table, "sortColumn", {default = nil, type = "number", canTriggerRender = true})
---@property sortDirection string "asc" Sort direction ("asc" or "desc")
Table.defineProperty(Table, "sortDirection", {default = "asc", type = "string", canTriggerRender = true})
---@property scrollOffset number 0 Current scroll position
Table.defineProperty(Table, "scrollOffset", {default = 0, type = "number", canTriggerRender = true})
---@property customSortFunction table {} Custom sort functions for columns
Table.defineProperty(Table, "customSortFunction", {default = {}, type = "table"})
---@property offset number 0 Scroll offset for vertical scrolling
Table.defineProperty(Table, "offset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
local maxOffset = math.max(0, #self.get("items") - (self.get("height") - 1))
return math.min(maxOffset, math.max(0, value))
end
})
---@property showScrollBar boolean true Whether to show the scrollbar when items exceed height
Table.defineProperty(Table, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string " " Symbol used for the scrollbar handle
Table.defineProperty(Table, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackground string "\127" Symbol used for the scrollbar background
Table.defineProperty(Table, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
Table.defineProperty(Table, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
Table.defineProperty(Table, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
---@event onRowSelect {rowIndex number, row table} Fired when a row is selected
Table.defineEvent(Table, "mouse_click")
Table.defineEvent(Table, "mouse_drag")
Table.defineEvent(Table, "mouse_up")
Table.defineEvent(Table, "mouse_scroll")
local entrySchema = {
cells = { type = "table", default = {} },
_sortValues = { type = "table", default = {} },
selected = { type = "boolean", default = false },
text = { type = "string", default = "" }
}
--- Creates a new Table instance
--- @shortDescription Creates a new Table instance
--- @return Table self The newly created Table instance
@@ -74,15 +94,97 @@ end
--- @return Table self The initialized instance
--- @protected
function Table:init(props, basalt)
VisualElement.init(self, props, basalt)
Collection.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "Table")
self:observe("sortColumn", function()
if self.get("sortColumn") then
self:sortByColumn(self.get("sortColumn"))
end
end)
return self
end
--- Adds a new row to the table
--- @shortDescription Adds a new row with cell values
--- @param ... any The cell values for the new row
--- @return Table self The Table instance
--- @usage table:addRow("Alice", 30, "USA")
function Table:addRow(...)
local cells = {...}
Collection.addItem(self, {
cells = cells,
_sortValues = cells, -- Store original values for sorting
text = table.concat(cells, " ") -- For compatibility if needed
})
return self
end
--- Removes a row by index
--- @shortDescription Removes a row at the specified index
--- @param rowIndex number The index of the row to remove
--- @return Table self The Table instance
function Table:removeRow(rowIndex)
local items = self.get("items")
if items[rowIndex] then
table.remove(items, rowIndex)
self.set("items", items)
end
return self
end
--- Gets a row by index
--- @shortDescription Gets the row data at the specified index
--- @param rowIndex number The index of the row
--- @return table? row The row data or nil
function Table:getRow(rowIndex)
local items = self.get("items")
return items[rowIndex]
end
--- Updates a specific cell value
--- @shortDescription Updates a cell value at row and column
--- @param rowIndex number The row index
--- @param colIndex number The column index
--- @param value any The new value
--- @return Table self The Table instance
function Table:updateCell(rowIndex, colIndex, value)
local items = self.get("items")
if items[rowIndex] and items[rowIndex].cells then
items[rowIndex].cells[colIndex] = value
self.set("items", items)
end
return self
end
--- Gets the currently selected row
--- @shortDescription Gets the currently selected row data
--- @return table? row The selected row or nil
function Table:getSelectedRow()
local items = self.get("items")
for _, item in ipairs(items) do
local isSelected = item._data and item._data.selected or item.selected
if isSelected then
return item
end
end
return nil
end
--- Clears all table data
--- @shortDescription Removes all rows from the table
--- @return Table self The Table instance
function Table:clearData()
self.set("items", {})
return self
end
--- Adds a new column to the table
--- @shortDescription Adds a new column to the table
--- @param name string The name of the column
--- @param width number The width of the column
--- @param width number|string The width of the column (number, "auto", or "30%")
--- @return Table self The Table instance
function Table:addColumn(name, width)
local columns = self.get("columns")
@@ -91,17 +193,6 @@ function Table:addColumn(name, width)
return self
end
--- Adds a new row of data to the table
--- @shortDescription Adds a new row of data to the table
--- @param ... any The data for the new row
--- @return Table self The Table instance
function Table:addData(...)
local data = self.get("data")
table.insert(data, {...})
self.set("data", data)
return self
end
--- Sets a custom sort function for a specific column
--- @shortDescription Sets a custom sort function for a column
--- @param columnIndex number The index of the column
@@ -114,56 +205,54 @@ function Table:setColumnSortFunction(columnIndex, sortFn)
return self
end
--- Adds data with both display and sort values
--- @shortDescription Adds formatted data with raw sort values
--- @param displayData table The formatted data for display
--- @param sortData table The raw data for sorting (optional)
--- Set data with automatic formatting
--- @shortDescription Sets table data with optional column formatters
--- @param rawData table The raw data array (array of row arrays)
--- @param formatters table? Optional formatter functions for columns {[2] = function(value) return value end}
--- @return Table self The Table instance
function Table:setFormattedData(displayData, sortData)
local enrichedData = {}
--- @usage table:setData({{...}}, {[1] = tostring, [2] = function(age) return age.."y" end})
function Table:setData(rawData, formatters)
self:clearData()
for i, row in ipairs(displayData) do
local enrichedRow = {}
for j, cell in ipairs(row) do
enrichedRow[j] = cell
for _, row in ipairs(rawData) do
local cells = {}
local sortValues = {}
for j, cellValue in ipairs(row) do
sortValues[j] = cellValue
if formatters and formatters[j] then
cells[j] = formatters[j](cellValue)
else
cells[j] = cellValue
end
end
if sortData and sortData[i] then
enrichedRow._sortValues = sortData[i]
end
table.insert(enrichedData, enrichedRow)
Collection.addItem(self, {
cells = cells,
_sortValues = sortValues,
text = table.concat(cells, " ")
})
end
self.set("data", enrichedData)
return self
end
--- Set data with automatic formatting
--- @shortDescription Sets table data with optional column formatters
--- @param rawData table The raw data array
--- @param formatters table Optional formatter functions for columns {[2] = function(value) return value end}
--- @return Table self The Table instance
function Table:setData(rawData, formatters)
if not formatters then
self.set("data", rawData)
return self
end
--- Gets all table data
--- @shortDescription Gets all rows as array of cell arrays
--- @return table data Array of row cell arrays
function Table:getData()
local items = self.get("items")
local data = {}
local formattedData = {}
for i, row in ipairs(rawData) do
local formattedRow = {}
for j, cell in ipairs(row) do
if formatters[j] then
formattedRow[j] = formatters[j](cell)
else
formattedRow[j] = cell
end
for _, item in ipairs(items) do
local cells = item._data and item._data.cells or item.cells
if cells then
table.insert(data, cells)
end
table.insert(formattedData, formattedRow)
end
return self:setFormattedData(formattedData, rawData)
return data
end
--- @shortDescription Calculates column widths for rendering
@@ -241,33 +330,38 @@ end
--- @param columnIndex number The index of the column to sort by
--- @param fn function? Optional custom sorting function
--- @return Table self The Table instance
function Table:sortData(columnIndex, fn)
local data = self.get("data")
function Table:sortByColumn(columnIndex, fn)
local items = self.get("items")
local direction = self.get("sortDirection")
local customSorts = self.get("customSortFunction")
local sortFn = fn or customSorts[columnIndex]
if sortFn then
table.sort(data, function(a, b)
table.sort(items, function(a, b)
return sortFn(a, b, direction)
end)
else
table.sort(data, function(a, b)
if not a or not b then return false end
table.sort(items, function(a, b)
local aCells = a._data and a._data.cells or a.cells
local bCells = b._data and b._data.cells or b.cells
local aSortValues = a._data and a._data._sortValues or a._sortValues
local bSortValues = b._data and b._data._sortValues or b._sortValues
if not a or not b or not aCells or not bCells then return false end
local valueA, valueB
if a._sortValues and a._sortValues[columnIndex] then
valueA = a._sortValues[columnIndex]
if aSortValues and aSortValues[columnIndex] then
valueA = aSortValues[columnIndex]
else
valueA = a[columnIndex]
valueA = aCells[columnIndex]
end
if b._sortValues and b._sortValues[columnIndex] then
valueB = b._sortValues[columnIndex]
if bSortValues and bSortValues[columnIndex] then
valueB = bSortValues[columnIndex]
else
valueB = b[columnIndex]
valueB = bCells[columnIndex]
end
if type(valueA) == "number" and type(valueB) == "number" then
@@ -287,23 +381,55 @@ function Table:sortData(columnIndex, fn)
end
end)
end
self.set("items", items)
return self
end
--- Registers callback for row selection
--- @shortDescription Registers a callback when a row is selected
--- @param callback function The callback function(rowIndex, row)
--- @return Table self The Table instance
function Table:onRowSelect(callback)
self:registerCallback("rowSelect", callback)
return self
end
--- @shortDescription Handles header clicks for sorting and row selection
--- @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 Table:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end
if not Collection.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local height = self.get("height")
local items = self.get("items")
local showScrollBar = self.get("showScrollBar")
local visibleRows = height - 1
if showScrollBar and #items > visibleRows and relX == width and relY > 1 then
local scrollBarHeight = height - 1
local maxOffset = #items - visibleRows
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local currentPercent = maxOffset > 0 and (self.get("offset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollBarHeight - handleSize)) + 1
local scrollBarRelY = relY - 1
if scrollBarRelY >= handlePos and scrollBarRelY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = scrollBarRelY - handlePos
else
local newPercent = ((scrollBarRelY - 1) / (scrollBarHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if relY == 1 then
local columns = self.get("columns")
local width = self.get("width")
local calculatedColumns = self:calculateColumnWidths(columns, width)
local currentX = 1
@@ -316,38 +442,93 @@ function Table:mouse_click(button, x, y)
self.set("sortColumn", i)
self.set("sortDirection", "asc")
end
self:sortData(i)
break
self:sortByColumn(i)
self:updateRender()
return true
end
currentX = currentX + colWidth
end
return true
end
if relY > 1 then
local rowIndex = relY - 2 + self.get("scrollOffset")
if rowIndex >= 0 and rowIndex < #self.get("data") then
self.set("selectedRow", rowIndex + 1)
local rowIndex = relY - 2 + self.get("offset")
if rowIndex >= 0 and rowIndex < #items then
local actualIndex = rowIndex + 1
for _, item in ipairs(items) do
if item._data then
item._data.selected = false
else
item.selected = false
end
end
if items[actualIndex] then
if items[actualIndex]._data then
items[actualIndex]._data.selected = true
else
items[actualIndex].selected = true
end
self:fireEvent("rowSelect", actualIndex, items[actualIndex])
self:updateRender()
end
end
return true
end
return true
end
--- @shortDescription Handles mouse drag events for scrollbar
--- @protected
function Table:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local items = self.get("items")
local height = self.get("height")
local visibleRows = height - 1
local scrollBarHeight = height - 1
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local maxOffset = #items - visibleRows
local scrollBarRelY = relY - 1
scrollBarRelY = math.max(1, math.min(scrollBarHeight, scrollBarRelY))
local newPos = scrollBarRelY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollBarHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return Collection.mouse_drag and Collection.mouse_drag(self, button, x, y) or false
end
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @protected
function Table:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
return Collection.mouse_up and Collection.mouse_up(self, button, x, y) or false
end
--- @shortDescription Handles scrolling through the table data
--- @param direction number The scroll direction (-1 up, 1 down)
--- @param x number The x position of the scroll
--- @param y number The y position of the scroll
--- @return boolean handled Whether the event was handled
--- @protected
function Table:mouse_scroll(direction, x, y)
if(VisualElement.mouse_scroll(self, direction, x, y))then
local data = self.get("data")
if Collection.mouse_scroll(self, direction, x, y) then
local items = self.get("items")
local height = self.get("height")
local visibleRows = height - 2
local maxScroll = math.max(0, #data - visibleRows - 1)
local newOffset = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction))
local visibleRows = height - 1 -- Subtract header
local maxOffset = math.max(0, #items - visibleRows)
local newOffset = math.min(maxOffset, math.max(0, self.get("offset") + direction))
self.set("scrollOffset", newOffset)
self.set("offset", newOffset)
self:updateRender()
return true
end
return false
@@ -356,21 +537,25 @@ end
--- @shortDescription Renders the table with headers, data and scrollbar
--- @protected
function Table:render()
VisualElement.render(self)
local columns = self.get("columns")
local data = self.get("data")
local selected = self.get("selectedRow")
local sortCol = self.get("sortColumn")
local scrollOffset = self.get("scrollOffset")
Collection.render(self)
local columns = self.getResolved("columns")
local items = self.getResolved("items")
local sortCol = self.getResolved("sortColumn")
local offset = self.getResolved("offset")
local height = self.get("height")
local width = self.get("width")
local showScrollBar = self.getResolved("showScrollBar")
local visibleRows = height - 1
local calculatedColumns = self:calculateColumnWidths(columns, width)
local needsScrollBar = showScrollBar and #items > visibleRows
local contentWidth = needsScrollBar and width - 1 or width
local calculatedColumns = self:calculateColumnWidths(columns, contentWidth)
local totalWidth = 0
local lastVisibleColumn = #calculatedColumns
for i, col in ipairs(calculatedColumns) do
if totalWidth + col.visibleWidth > width then
if totalWidth + col.visibleWidth > contentWidth then
lastVisibleColumn = i - 1
break
end
@@ -388,32 +573,68 @@ function Table:render()
currentX = currentX + col.visibleWidth
end
if currentX <= contentWidth then
self:textBg(currentX, 1, string.rep(" ", contentWidth - currentX + 1), self.get("background"))
end
for y = 2, height do
local rowIndex = y - 2 + scrollOffset
local rowData = data[rowIndex + 1]
local rowIndex = y - 2 + offset
local item = items[rowIndex + 1]
if rowData and (rowIndex + 1) <= #data then
currentX = 1
local bg = (rowIndex + 1) == selected and self.get("selectedColor") or self.get("background")
if item then
local cells = item._data and item._data.cells or item.cells
local isSelected = item._data and item._data.selected or item.selected
for i, col in ipairs(calculatedColumns) do
if i > lastVisibleColumn then break end
local cellText = tostring(rowData[i] or "")
local paddedText = cellText .. string.rep(" ", col.visibleWidth - #cellText)
if i < lastVisibleColumn then
paddedText = string.sub(paddedText, 1, col.visibleWidth - 1) .. " "
if cells then
currentX = 1
local bg = isSelected and self.get("selectedBackground") or self.get("background")
for i, col in ipairs(calculatedColumns) do
if i > lastVisibleColumn then break end
local cellText = tostring(cells[i] or "")
local paddedText = cellText .. string.rep(" ", col.visibleWidth - #cellText)
if i < lastVisibleColumn then
paddedText = string.sub(paddedText, 1, col.visibleWidth - 1) .. " "
end
local finalText = string.sub(paddedText, 1, col.visibleWidth)
local finalForeground = string.rep(tHex[self.get("foreground")], col.visibleWidth)
local finalBackground = string.rep(tHex[bg], col.visibleWidth)
self:blit(currentX, y, finalText, finalForeground, finalBackground)
currentX = currentX + col.visibleWidth
end
local finalText = string.sub(paddedText, 1, col.visibleWidth)
local finalForeground = string.rep(tHex[self.get("foreground")], col.visibleWidth)
local finalBackground = string.rep(tHex[bg], col.visibleWidth)
self:blit(currentX, y, finalText, finalForeground, finalBackground)
currentX = currentX + col.visibleWidth
if currentX <= contentWidth then
self:textBg(currentX, y, string.rep(" ", contentWidth - currentX + 1), bg)
end
end
else
self:blit(1, y, string.rep(" ", self.get("width")),
string.rep(tHex[self.get("foreground")], self.get("width")),
string.rep(tHex[self.get("background")], self.get("width")))
self:blit(1, y, string.rep(" ", contentWidth),
string.rep(tHex[self.get("foreground")], contentWidth),
string.rep(tHex[self.get("background")], contentWidth))
end
end
if needsScrollBar then
local scrollBarHeight = height - 1
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local maxOffset = #items - visibleRows
local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollBarHeight - handleSize)) + 1
local scrollBarSymbol = self.getResolved("scrollBarSymbol")
local scrollBarBg = self.getResolved("scrollBarBackground")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local foreground = self.getResolved("foreground")
for i = 2, height do
self:blit(width, i, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollBarHeight, handlePos + handleSize - 1) do
self:blit(width, i + 1, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end
end
end

View File

@@ -3,6 +3,18 @@ local sub = string.sub
local tHex = require("libraries/colorHex")
---@cofnigDescription The tree element provides a hierarchical view of nodes that can be expanded and collapsed, with support for selection and scrolling.
local function flattenTree(nodes, expandedNodes, level, result)
result = result or {}
level = level or 0
for _, node in ipairs(nodes) do
table.insert(result, {node = node, level = level})
if expandedNodes[node] and node.children then
flattenTree(node.children, expandedNodes, level + 1, result)
end
end
return result
end
--- This is the tree class. It provides a hierarchical view of nodes that can be expanded and collapsed,
--- with support for selection and scrolling.
@@ -21,16 +33,47 @@ end})
Tree.defineProperty(Tree, "selectedNode", {default = nil, type = "table", canTriggerRender = true})
---@property expandedNodes table {} Table of nodes that are currently expanded
Tree.defineProperty(Tree, "expandedNodes", {default = {}, type = "table", canTriggerRender = true})
---@property scrollOffset number 0 Current vertical scroll position
Tree.defineProperty(Tree, "scrollOffset", {default = 0, type = "number", canTriggerRender = true})
---@property offset number 0 Current vertical scroll position
Tree.defineProperty(Tree, "offset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
return math.max(0, value)
end
})
---@property horizontalOffset number 0 Current horizontal scroll position
Tree.defineProperty(Tree, "horizontalOffset", {default = 0, type = "number", canTriggerRender = true})
Tree.defineProperty(Tree, "horizontalOffset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
return math.max(0, value)
end
})
---@property selectedForegroundColor color white foreground color of selected node
Tree.defineProperty(Tree, "selectedForegroundColor", {default = colors.white, type = "color"})
---@property selectedBackgroundColor color lightBlue background color of selected node
Tree.defineProperty(Tree, "selectedBackgroundColor", {default = colors.lightBlue, type = "color"})
---@property showScrollBar boolean true Whether to show the scrollbar when nodes exceed height
Tree.defineProperty(Tree, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string " " Symbol used for the scrollbar handle
Tree.defineProperty(Tree, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackground string "\127" Symbol used for the scrollbar background
Tree.defineProperty(Tree, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
Tree.defineProperty(Tree, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
Tree.defineProperty(Tree, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
Tree.defineEvent(Tree, "mouse_click")
Tree.defineEvent(Tree, "mouse_drag")
Tree.defineEvent(Tree, "mouse_up")
Tree.defineEvent(Tree, "mouse_scroll")
--- Creates a new Tree instance
@@ -91,19 +134,6 @@ function Tree:toggleNode(node)
return self
end
local function flattenTree(nodes, expandedNodes, level, result)
result = result or {}
level = level or 0
for _, node in ipairs(nodes) do
table.insert(result, {node = node, level = level})
if expandedNodes[node] and node.children then
flattenTree(node.children, expandedNodes, level + 1, result)
end
end
return result
end
--- Handles mouse click events
--- @shortDescription Handles mouse click events for node selection and expansion
--- @param button number The button that was clicked
@@ -114,8 +144,54 @@ end
function Tree:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local height = self.get("height")
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local visibleIndex = relY + self.get("scrollOffset")
local showScrollBar = self.get("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and #flatNodes > contentHeight
if needsVerticalScrollBar and relX == width and (not needsHorizontalScrollBar or relY < height) then
local scrollHeight = needsHorizontalScrollBar and height - 1 or height
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
local currentPercent = maxOffset > 0 and (self.get("offset") / 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("offset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if needsHorizontalScrollBar and relY == height and (not needsVerticalScrollBar or relX < width) then
local contentWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((contentWidth / maxContentWidth) * contentWidth))
local maxOffset = maxContentWidth - contentWidth
local currentPercent = maxOffset > 0 and (self.get("horizontalOffset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (contentWidth - handleSize)) + 1
if relX >= handlePos and relX < handlePos + handleSize then
self._hScrollBarDragging = true
self._hScrollBarDragOffset = relX - handlePos
else
local newPercent = ((relX - 1) / (contentWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("horizontalOffset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
local visibleIndex = relY + self.get("offset")
if flatNodes[visibleIndex] then
local nodeInfo = flatNodes[visibleIndex]
@@ -142,6 +218,82 @@ function Tree:onSelect(callback)
return self
end
--- @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 Tree:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local height = self.get("height")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = self.get("showScrollBar") and maxContentWidth > self.get("width")
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local scrollHeight = contentHeight
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
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("offset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
if self._hScrollBarDragging then
local relX, _ = self:getRelativePosition(x, y)
local width = self.get("width")
local maxContentWidth, _ = self:getNodeSize()
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local height = self.get("height")
local needsHorizontalScrollBar = self.get("showScrollBar") and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.get("showScrollBar") and #flatNodes > contentHeight
local contentWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((contentWidth / maxContentWidth) * contentWidth))
local maxOffset = maxContentWidth - contentWidth
relX = math.max(1, math.min(contentWidth, relX))
local newPos = relX - (self._hScrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (contentWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("horizontalOffset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return VisualElement.mouse_drag and VisualElement.mouse_drag(self, button, x, y) or false
end
--- @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 Tree: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 VisualElement.mouse_up and VisualElement.mouse_up(self, button, x, y) or false
end
--- @shortDescription Handles mouse scroll events for vertical scrolling
--- @param direction number The scroll direction (1 for up, -1 for down)
--- @param x number The x position of the scroll
@@ -151,10 +303,16 @@ end
function Tree:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local maxScroll = math.max(0, #flatNodes - self.get("height"))
local newScroll = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction))
local height = self.get("height")
local width = self.get("width")
local showScrollBar = self.get("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local maxScroll = math.max(0, #flatNodes - contentHeight)
local newScroll = math.min(maxScroll, math.max(0, self.get("offset") + direction))
self.set("scrollOffset", newScroll)
self.set("offset", newScroll)
return true
end
return false
@@ -167,8 +325,20 @@ end
function Tree:getNodeSize()
local width, height = 0, 0
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local expandedNodes = self.get("expandedNodes")
for _, nodeInfo in ipairs(flatNodes) do
width = math.max(width, nodeInfo.level + #nodeInfo.node.text)
local node = nodeInfo.node
local level = nodeInfo.level
local indent = string.rep(" ", level)
local symbol = " "
if node.children and #node.children > 0 then
symbol = expandedNodes[node] and "\31" or "\16"
end
local fullText = indent .. symbol .. " " .. (node.text or "Node")
width = math.max(width, #fullText)
end
height = #flatNodes
return width, height
@@ -181,13 +351,20 @@ function Tree:render()
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local height = self.get("height")
local width = self.get("width")
local selectedNode = self.get("selectedNode")
local expandedNodes = self.get("expandedNodes")
local scrollOffset = self.get("scrollOffset")
local offset = self.get("offset")
local horizontalOffset = self.get("horizontalOffset")
local showScrollBar = self.get("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and #flatNodes > contentHeight
local contentWidth = needsVerticalScrollBar and width - 1 or width
for y = 1, height do
local nodeInfo = flatNodes[y + scrollOffset]
for y = 1, contentHeight do
local nodeInfo = flatNodes[y + offset]
if nodeInfo then
local node = nodeInfo.node
local level = nodeInfo.level
@@ -203,17 +380,61 @@ function Tree:render()
local _fg = isSelected and self.get("selectedForegroundColor") or (node.foreground or node.fg or self.get("foreground"))
local fullText = indent .. symbol .. " " .. (node.text or "Node")
local text = sub(fullText, horizontalOffset + 1, horizontalOffset + self.get("width"))
local paddedText = text .. string.rep(" ", self.get("width") - #text)
local text = sub(fullText, horizontalOffset + 1, horizontalOffset + contentWidth)
local paddedText = text .. string.rep(" ", contentWidth - #text)
local bg = tHex[_bg]:rep(#paddedText) or tHex[colors.black]:rep(#paddedText)
local fg = tHex[_fg]:rep(#paddedText) or tHex[colors.white]:rep(#paddedText)
self:blit(1, y, paddedText, fg, bg)
else
self:blit(1, y, string.rep(" ", self.get("width")), tHex[self.get("foreground")]:rep(self.get("width")), tHex[self.get("background")]:rep(self.get("width")))
self:blit(1, y, string.rep(" ", contentWidth), tHex[self.get("foreground")]:rep(contentWidth), tHex[self.get("background")]:rep(contentWidth))
end
end
local scrollBarSymbol = self.getResolved("scrollBarSymbol")
local scrollBarBg = self.getResolved("scrollBarBackground")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local foreground = self.getResolved("foreground")
if needsVerticalScrollBar then
local scrollHeight = needsHorizontalScrollBar and height - 1 or height
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
for i = 1, scrollHeight do
self:blit(width, i, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollHeight, handlePos + handleSize - 1) do
self:blit(width, i, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end
end
if needsHorizontalScrollBar then
local scrollWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((scrollWidth / maxContentWidth) * scrollWidth))
local maxOffset = maxContentWidth - contentWidth
local currentPercent = maxOffset > 0 and (horizontalOffset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
for i = 1, scrollWidth do
self:blit(i, height, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollWidth, handlePos + handleSize - 1) do
self:blit(i, height, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end
end
if needsVerticalScrollBar and needsHorizontalScrollBar then
self:blit(width, height, " ", tHex[foreground], tHex[self.get("background")])
end
end
return Tree

View File

@@ -889,7 +889,7 @@ function VisualElement:isFocused()
return self:hasState("focused")
end
--- Adds or updates a drawable character border around the element using the canvas plugin. The border will automatically adapt to size/background changes because the command reads current properties each render.
--- Adds or updates a drawable character border around the element. The border will automatically adapt to size/background changes because the command reads current properties each render.
--- @param colorOrOptions any Border color or options table
--- @param sideOptions? table Side options table (if color is provided as first argument)
--- @return VisualElement self

View File

@@ -23,6 +23,8 @@ local eventParser = require("parsers.eventParser")
local globalParser = require("parsers.globalParser")
local helper = require("utils.helper")
local markdownGenerator = require("utils.markdownGenerator")
BasaltDoc.annotationHandlers = {}
@@ -145,6 +147,31 @@ BasaltDoc.registerAnnotation("@globalDescription", function(target, args)
end
end)
BasaltDoc.registerAnnotation("@tableType", function(target, args)
if not target.tableTypes then target.tableTypes = {} end
local tableName = args:match("^%s*(%S+)")
if tableName then
target._currentTableType = {
name = tableName,
fields = {}
}
table.insert(target.tableTypes, target._currentTableType)
end
end)
BasaltDoc.registerAnnotation("@tableField", function(target, args)
if target._currentTableType then
local fieldName, fieldType, fieldDesc = args:match("^%s*([%w_]+)%s+([%w_|]+)%s+(.*)")
if fieldName and fieldType then
table.insert(target._currentTableType.fields, {
name = fieldName,
type = fieldType,
description = fieldDesc or ""
})
end
end
end)
if classParser then classParser.setHandlers(BasaltDoc.annotationHandlers) end
if functionParser then functionParser.setHandlers(BasaltDoc.annotationHandlers) end
if propertyParser then propertyParser.setHandlers(BasaltDoc.annotationHandlers) end
@@ -192,12 +219,14 @@ function BasaltDoc.parse(content)
local annotationBuffer = {}
local currentClass = nil
local firstTag = nil
local pendingTableTypes = {}
local blockStartTags = {
["@class"] = true,
["@property"] = true,
["@event"] = true,
["@skip"] = true
["@skip"] = true,
["@tableType"] = true
}
local i = 1
@@ -225,9 +254,25 @@ function BasaltDoc.parse(content)
if firstTag == "@class" and classParser then
local class = classParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
if class and not class.skip then
if #pendingTableTypes > 0 then
for _, tableType in ipairs(pendingTableTypes) do
table.insert(class.tableTypes, tableType)
end
pendingTableTypes = {}
end
table.insert(ast.classes, class)
currentClass = class
end
elseif firstTag == "@tableType" then
local tempTarget = {tableTypes = {}}
if classParser and classParser.handlers then
helper.applyAnnotations(annotationBuffer, tempTarget, classParser.handlers)
end
if tempTarget.tableTypes and #tempTarget.tableTypes > 0 then
for _, tt in ipairs(tempTarget.tableTypes) do
table.insert(pendingTableTypes, tt)
end
end
elseif firstTag == "@property" and currentClass and propertyParser then
local prop = propertyParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
if prop then

View File

@@ -17,6 +17,7 @@ function classParser.parse(annotations, line)
properties = {},
events = {},
functions = {},
tableTypes = {},
skip = false
}

View File

@@ -7,7 +7,36 @@ function helper.applyAnnotations(annotations, target, handlers)
local tag, args = ann:match("^%-%-%-?%s*(@%S+)%s*(.*)")
if tag then
if args == ">" then
if args and args:match("^%s*%[%[") then
local blockContent = args:gsub("^%s*%[%[%s*", "")
if blockContent:match("%]%]%s*$") then
args = blockContent:gsub("%]%]%s*$", "")
else
local multiArgs = {}
if blockContent ~= "" then
table.insert(multiArgs, blockContent)
end
i = i + 1
while i <= #annotations do
local nextAnn = annotations[i]
local content = nextAnn:match("^%-%-%-?%s*(.*)") or nextAnn
if content:match("%]%]%s*$") then
local finalContent = content:gsub("%]%]%s*$", "")
if finalContent ~= "" then
table.insert(multiArgs, finalContent)
end
break
else
table.insert(multiArgs, content)
end
i = i + 1
end
args = table.concat(multiArgs, "\n")
end
elseif args == ">" then
local multiArgs = ""
i = i + 1

View File

@@ -72,16 +72,21 @@ local function generateFunctionMarkdown(class, functions)
if f.usage then
table.insert(md, "### Usage")
table.insert(md, "```lua")
for _, usage in ipairs(f.usage) do
if usage == "" then
table.insert(md, "")
else
table.insert(md, usage)
for _, usageBlock in ipairs(f.usage) do
table.insert(md, "```lua run")
-- Check if usageBlock is already multi-line
if type(usageBlock) == "string" then
if usageBlock:match("\n") then
-- Multi-line block
table.insert(md, usageBlock)
else
-- Single line
table.insert(md, usageBlock)
end
end
table.insert(md, "```")
table.insert(md, "")
end
table.insert(md, "```")
table.insert(md, "")
end
if f.run then
@@ -157,6 +162,38 @@ function markdownGenerator.generate(ast)
end
table.insert(md, "")
if class.usage then
table.insert(md, "## Usage")
for _, usageBlock in ipairs(class.usage) do
table.insert(md, "```lua run")
if type(usageBlock) == "string" then
table.insert(md, usageBlock)
end
table.insert(md, "```")
table.insert(md, "")
end
end
if #class.tableTypes > 0 then
table.insert(md, "## Table Types")
table.insert(md, "")
for _, tableType in ipairs(class.tableTypes) do
table.insert(md, "### " .. tableType.name)
table.insert(md, "")
if #tableType.fields > 0 then
table.insert(md, "|Property|Type|Description|")
table.insert(md, "|---|---|---|")
for _, field in ipairs(tableType.fields) do
table.insert(md, string.format("|%s|%s|%s|",
field.name or "",
field.type or "any",
field.description or ""))
end
table.insert(md, "")
end
end
end
if not class.skipPropertyList and #class.properties > 0 then
table.insert(md, "## Properties")
table.insert(md, "")