From 6f14eadf0a1a3fd488c92470ef9550c10ed02055 Mon Sep 17 00:00:00 2001 From: Robert Jelic <36573031+NoryiE@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:55:29 +0100 Subject: [PATCH] - 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 --- .gitignore | 3 +- src/elements/DropDown.lua | 104 ++--- src/elements/List.lua | 10 +- src/elements/Menu.lua | 194 ++++++-- src/elements/ScrollFrame.lua | 330 ++++++++++++++ src/elements/Table.lua | 479 ++++++++++++++------ src/elements/Tree.lua | 275 +++++++++-- src/elements/VisualElement.lua | 2 +- tools/BasaltDoc/init.lua | 47 +- tools/BasaltDoc/parsers/classParser.lua | 1 + tools/BasaltDoc/utils/helper.lua | 31 +- tools/BasaltDoc/utils/markdownGenerator.lua | 53 ++- 12 files changed, 1264 insertions(+), 265 deletions(-) create mode 100644 src/elements/ScrollFrame.lua diff --git a/.gitignore b/.gitignore index e0c4148..0318eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ Drawer.lua Breadcrumb.lua Dialog.lua DockLayout.lua -ContextMenu.lua \ No newline at end of file +ContextMenu.lua +Toast.lua \ No newline at end of file diff --git a/src/elements/DropDown.lua b/src/elements/DropDown.lua index acc64b5..fc7432c 100644 --- a/src/elements/DropDown.lua +++ b/src/elements/DropDown.lua @@ -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 diff --git a/src/elements/List.lua b/src/elements/List.lua index 55ec63d..1f9fc70 100644 --- a/src/elements/List.lua +++ b/src/elements/List.lua @@ -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 diff --git a/src/elements/Menu.lua b/src/elements/Menu.lua index ba8b21b..d271b1f 100644 --- a/src/elements/Menu.lua +++ b/src/elements/Menu.lua @@ -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 diff --git a/src/elements/ScrollFrame.lua b/src/elements/ScrollFrame.lua new file mode 100644 index 0000000..68369af --- /dev/null +++ b/src/elements/ScrollFrame.lua @@ -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 diff --git a/src/elements/Table.lua b/src/elements/Table.lua index aadb5c8..55688f2 100644 --- a/src/elements/Table.lua +++ b/src/elements/Table.lua @@ -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 diff --git a/src/elements/Tree.lua b/src/elements/Tree.lua index f28af11..cfa6784 100644 --- a/src/elements/Tree.lua +++ b/src/elements/Tree.lua @@ -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 \ No newline at end of file diff --git a/src/elements/VisualElement.lua b/src/elements/VisualElement.lua index 6d4a8d5..fe0d830 100644 --- a/src/elements/VisualElement.lua +++ b/src/elements/VisualElement.lua @@ -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 diff --git a/tools/BasaltDoc/init.lua b/tools/BasaltDoc/init.lua index 2eb906f..cdfac69 100644 --- a/tools/BasaltDoc/init.lua +++ b/tools/BasaltDoc/init.lua @@ -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 diff --git a/tools/BasaltDoc/parsers/classParser.lua b/tools/BasaltDoc/parsers/classParser.lua index f9de7c1..d1f25de 100644 --- a/tools/BasaltDoc/parsers/classParser.lua +++ b/tools/BasaltDoc/parsers/classParser.lua @@ -17,6 +17,7 @@ function classParser.parse(annotations, line) properties = {}, events = {}, functions = {}, + tableTypes = {}, skip = false } diff --git a/tools/BasaltDoc/utils/helper.lua b/tools/BasaltDoc/utils/helper.lua index 9f9df55..eaf4619 100644 --- a/tools/BasaltDoc/utils/helper.lua +++ b/tools/BasaltDoc/utils/helper.lua @@ -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 diff --git a/tools/BasaltDoc/utils/markdownGenerator.lua b/tools/BasaltDoc/utils/markdownGenerator.lua index e9a73ee..c130438 100644 --- a/tools/BasaltDoc/utils/markdownGenerator.lua +++ b/tools/BasaltDoc/utils/markdownGenerator.lua @@ -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, "")