diff --git a/.gitignore b/.gitignore index 0318eb3..b0dfc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,6 @@ Flexbox2.lua markdown.lua markdown2.lua SplitPane.lua -Accordion.lua Stepper.lua Drawer.lua -Breadcrumb.lua -Dialog.lua -DockLayout.lua -ContextMenu.lua -Toast.lua \ No newline at end of file +DockLayout.lua \ No newline at end of file diff --git a/src/elements/Accordion.lua b/src/elements/Accordion.lua new file mode 100644 index 0000000..434f53a --- /dev/null +++ b/src/elements/Accordion.lua @@ -0,0 +1,471 @@ +local elementManager = require("elementManager") +local VisualElement = require("elements/VisualElement") +local Container = elementManager.getElement("Container") +local tHex = require("libraries/colorHex") +---@configDescription An Accordion element that provides collapsible panels with headers. + +--- The Accordion is a container that provides collapsible panel functionality +--- @run [[ +--- local basalt = require("basalt") +--- +--- local main = basalt.getMainFrame() +--- +--- -- Create an Accordion +--- local accordion = main:addAccordion({ +--- x = 2, +--- y = 2, +--- width = 30, +--- height = 15, +--- allowMultiple = true, -- Only one panel open at a time +--- headerBackground = colors.gray, +--- headerTextColor = colors.white, +--- expandedHeaderBackground = colors.lightBlue, +--- expandedHeaderTextColor = colors.white, +--- }) +--- +--- -- Panel 1: Info +--- local infoPanel = accordion:newPanel("Information", true) -- starts expanded +--- infoPanel:addLabel({ +--- x = 2, +--- y = 1, +--- text = "This is an accordion", +--- foreground = colors.yellow +--- }) +--- infoPanel:addLabel({ +--- x = 2, +--- y = 2, +--- text = "with collapsible panels.", +--- foreground = colors.white +--- }) +--- +--- -- Panel 2: Settings +--- local settingsPanel = accordion:newPanel("Settings", false) +--- settingsPanel:addLabel({ +--- x = 2, +--- y = 1, +--- text = "Volume:", +--- foreground = colors.white +--- }) +--- local volumeSlider = settingsPanel:addSlider({ +--- x = 10, +--- y = 1, +--- width = 15, +--- value = 50 +--- }) +--- settingsPanel:addLabel({ +--- x = 2, +--- y = 3, +--- text = "Auto-save:", +--- foreground = colors.white +--- }) +--- settingsPanel:addSwitch({ +--- x = 13, +--- y = 3, +--- }) +--- +--- -- Panel 3: Actions +--- local actionsPanel = accordion:newPanel("Actions", false) +--- local statusLabel = actionsPanel:addLabel({ +--- x = 2, +--- y = 4, +--- text = "Ready", +--- foreground = colors.lime +--- }) +--- +--- actionsPanel:addButton({ +--- x = 2, +--- y = 1, +--- width = 10, +--- height = 1, +--- text = "Save", +--- background = colors.green, +--- foreground = colors.white, +--- }) +--- +--- actionsPanel:addButton({ +--- x = 14, +--- y = 1, +--- width = 10, +--- height = 1, +--- text = "Cancel", +--- background = colors.red, +--- foreground = colors.white, +--- }) +--- +--- -- Panel 4: About +--- local aboutPanel = accordion:newPanel("About", false) +--- aboutPanel:addLabel({ +--- x = 2, +--- y = 1, +--- text = "Basalt Accordion v1.0", +--- foreground = colors.white +--- }) +--- aboutPanel:addLabel({ +--- x = 2, +--- y = 2, +--- text = "A collapsible panel", +--- foreground = colors.gray +--- }) +--- aboutPanel:addLabel({ +--- x = 2, +--- y = 3, +--- text = "component for UI.", +--- foreground = colors.gray +--- }) +--- +--- -- Instructions +--- main:addLabel({ +--- x = 2, +--- y = 18, +--- text = "Click panel headers to expand/collapse", +--- foreground = colors.lightGray +--- }) +--- +--- basalt.run() +--- ]] +---@class Accordion : Container +local Accordion = setmetatable({}, Container) +Accordion.__index = Accordion + +---@property panels table {} List of panel definitions +Accordion.defineProperty(Accordion, "panels", {default = {}, type = "table"}) +---@property panelHeaderHeight number 1 Height of each panel header +Accordion.defineProperty(Accordion, "panelHeaderHeight", {default = 1, type = "number", canTriggerRender = true}) +---@property allowMultiple boolean false Allow multiple panels to be open at once +Accordion.defineProperty(Accordion, "allowMultiple", {default = false, type = "boolean"}) + +---@property headerBackground color gray Background color for panel headers +Accordion.defineProperty(Accordion, "headerBackground", {default = colors.gray, type = "color", canTriggerRender = true}) +---@property headerTextColor color white Text color for panel headers +Accordion.defineProperty(Accordion, "headerTextColor", {default = colors.white, type = "color", canTriggerRender = true}) +---@property expandedHeaderBackground color lightGray Background color for expanded panel headers +Accordion.defineProperty(Accordion, "expandedHeaderBackground", {default = colors.lightGray, type = "color", canTriggerRender = true}) +---@property expandedHeaderTextColor color black Text color for expanded panel headers +Accordion.defineProperty(Accordion, "expandedHeaderTextColor", {default = colors.black, type = "color", canTriggerRender = true}) + +Accordion.defineEvent(Accordion, "mouse_click") +Accordion.defineEvent(Accordion, "mouse_up") + +--- @shortDescription Creates a new Accordion instance +--- @return Accordion self The created instance +--- @private +function Accordion.new() + local self = setmetatable({}, Accordion):__init() + self.class = Accordion + self.set("width", 20) + self.set("height", 10) + self.set("z", 10) + return self +end + +--- @shortDescription Initializes the Accordion instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @protected +function Accordion:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "Accordion") +end + +--- Creates a new panel and returns the panel's container +--- @shortDescription Creates a new accordion panel +--- @param title string The title of the panel +--- @param expanded boolean Whether the panel starts expanded (default: false) +--- @return table panelContainer The container for this panel +function Accordion:newPanel(title, expanded) + local panels = self.get("panels") or {} + local panelId = #panels + 1 + + local panelContainer = self:addContainer() + panelContainer.set("x", 1) + panelContainer.set("y", 1) + panelContainer.set("width", self.get("width")) + panelContainer.set("height", self.get("height")) + panelContainer.set("visible", expanded or false) + panelContainer.set("ignoreOffset", true) + + table.insert(panels, { + id = panelId, + title = tostring(title or ("Panel " .. panelId)), + expanded = expanded or false, + container = panelContainer + }) + + self.set("panels", panels) + self:updatePanelLayout() + + return panelContainer +end +Accordion.addPanel = Accordion.newPanel + +--- @shortDescription Updates the layout of all panels (positions and visibility) +--- @private +function Accordion:updatePanelLayout() + local panels = self.get("panels") or {} + local headerHeight = self.get("panelHeaderHeight") or 1 + local currentY = 1 + local width = self.get("width") + local accordionHeight = self.get("height") + + for _, panel in ipairs(panels) do + local contentY = currentY + headerHeight + + panel.container.set("x", 1) + panel.container.set("y", contentY) + panel.container.set("width", width) + panel.container.set("visible", panel.expanded) + panel.container.set("ignoreOffset", false) + + currentY = currentY + headerHeight + if panel.expanded then + local maxY = 0 + for _, child in ipairs(panel.container._values.children or {}) do + if not child._destroyed then + local childY = child.get("y") + local childH = child.get("height") + local childBottom = childY + childH - 1 + if childBottom > maxY then + maxY = childBottom + end + end + end + local contentHeight = math.max(1, maxY) + panel.container.set("height", contentHeight) + currentY = currentY + contentHeight + end + end + + local totalHeight = currentY - 1 + local maxOffset = math.max(0, totalHeight - accordionHeight) + local currentOffset = self.get("offsetY") + + if currentOffset > maxOffset then + self.set("offsetY", maxOffset) + end + + self:updateRender() +end + +--- @shortDescription Toggles a panel's expanded state +--- @param panelId number The ID of the panel to toggle +--- @return Accordion self For method chaining +function Accordion:togglePanel(panelId) + local panels = self.get("panels") or {} + local allowMultiple = self.get("allowMultiple") + + for i, panel in ipairs(panels) do + if panel.id == panelId then + panel.expanded = not panel.expanded + + if not allowMultiple and panel.expanded then + for j, otherPanel in ipairs(panels) do + if j ~= i then + otherPanel.expanded = false + end + end + end + + self:updatePanelLayout() + self:dispatchEvent("panelToggled", panelId, panel.expanded) + break + end + end + + return self +end + +--- @shortDescription Expands a specific panel +--- @param panelId number The ID of the panel to expand +--- @return Accordion self For method chaining +function Accordion:expandPanel(panelId) + local panels = self.get("panels") or {} + local allowMultiple = self.get("allowMultiple") + + for i, panel in ipairs(panels) do + if panel.id == panelId then + if not panel.expanded then + panel.expanded = true + + if not allowMultiple then + for j, otherPanel in ipairs(panels) do + if j ~= i then + otherPanel.expanded = false + end + end + end + + self:updatePanelLayout() + self:dispatchEvent("panelToggled", panelId, true) + end + break + end + end + + return self +end + +--- @shortDescription Collapses a specific panel +--- @param panelId number The ID of the panel to collapse +--- @return Accordion self For method chaining +function Accordion:collapsePanel(panelId) + local panels = self.get("panels") or {} + + for _, panel in ipairs(panels) do + if panel.id == panelId then + if panel.expanded then + panel.expanded = false + self:updatePanelLayout() + self:dispatchEvent("panelToggled", panelId, false) + end + break + end + end + + return self +end + +--- @shortDescription Gets a panel container by ID +--- @param panelId number The ID of the panel +--- @return table? container The panel's container or nil +function Accordion:getPanel(panelId) + local panels = self.get("panels") or {} + for _, panel in ipairs(panels) do + if panel.id == panelId then + return panel.container + end + end + return nil +end + +--- @shortDescription Calculates panel header positions for rendering +--- @return table metrics Panel layout information +--- @private +function Accordion:_getPanelMetrics() + local panels = self.get("panels") or {} + local headerHeight = self.get("panelHeaderHeight") or 1 + + local positions = {} + local currentY = 1 + + for _, panel in ipairs(panels) do + table.insert(positions, { + id = panel.id, + title = panel.title, + expanded = panel.expanded, + headerY = currentY, + headerHeight = headerHeight + }) + + currentY = currentY + headerHeight + if panel.expanded then + currentY = currentY + panel.container.get("height") + end + end + + return { + positions = positions, + totalHeight = currentY - 1 + } +end + +--- @shortDescription Handles mouse click events for panel toggling +--- @param button number The button that was clicked +--- @param x number The x position of the click (global) +--- @param y number The y position of the click (global) +--- @return boolean Whether the event was handled +--- @protected +function Accordion:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then + return false + end + + local relX, relY = VisualElement.getRelativePosition(self, x, y) + local offsetY = self.get("offsetY") + local adjustedY = relY + offsetY + local metrics = self:_getPanelMetrics() + + for _, panelInfo in ipairs(metrics.positions) do + local headerEndY = panelInfo.headerY + panelInfo.headerHeight - 1 + if adjustedY >= panelInfo.headerY and adjustedY <= headerEndY then + self:togglePanel(panelInfo.id) + self.set("focusedChild", nil) + return true + end + end + + return Container.mouse_click(self, button, x, y) +end + +function Accordion:mouse_scroll(direction, x, y) + if VisualElement.mouse_scroll(self, direction, x, y) then + local metrics = self:_getPanelMetrics() + local accordionHeight = self.get("height") + local totalHeight = metrics.totalHeight + local maxOffset = math.max(0, totalHeight - accordionHeight) + + if maxOffset > 0 then + local currentOffset = self.get("offsetY") + local newOffset = currentOffset + direction + newOffset = math.max(0, math.min(maxOffset, newOffset)) + self.set("offsetY", newOffset) + return true + end + + return Container.mouse_scroll(self, direction, x, y) + end + return false +end + +--- @shortDescription Renders the Accordion (headers + panel containers) +--- @protected +function Accordion:render() + VisualElement.render(self) + + local width = self.get("width") + local offsetY = self.get("offsetY") + local metrics = self:_getPanelMetrics() + + for _, panelInfo in ipairs(metrics.positions) do + local bgColor = panelInfo.expanded and self.get("expandedHeaderBackground") or self.get("headerBackground") + local fgColor = panelInfo.expanded and self.get("expandedHeaderTextColor") or self.get("headerTextColor") + + local headerY = panelInfo.headerY - offsetY + + if headerY >= 1 and headerY <= self.get("height") then + VisualElement.multiBlit( + self, + 1, + headerY, + width, + panelInfo.headerHeight, + " ", + tHex[fgColor], + tHex[bgColor] + ) + + local indicator = panelInfo.expanded and "v" or ">" + local headerText = indicator .. " " .. panelInfo.title + VisualElement.textFg(self, 1, headerY, headerText, fgColor) + end + end + + if not self.get("childrenSorted") then + self:sortChildren() + end + if not self.get("childrenEventsSorted") then + for eventName in pairs(self._values.childrenEvents or {}) do + self:sortChildrenEvents(eventName) + end + end + + for _, child in ipairs(self.get("visibleChildren") or {}) do + if child == self then + error("CIRCULAR REFERENCE DETECTED!") + return + end + child:render() + child:postRender() + end +end + +return Accordion \ No newline at end of file diff --git a/src/elements/Breadcrumb.lua b/src/elements/Breadcrumb.lua new file mode 100644 index 0000000..7c16b7e --- /dev/null +++ b/src/elements/Breadcrumb.lua @@ -0,0 +1,125 @@ +local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") +local tHex = require("libraries/colorHex") + +---@class Breadcrumb : VisualElement +local Breadcrumb = setmetatable({}, VisualElement) +Breadcrumb.__index = Breadcrumb + +---@property path table {} Array of strings representing the breadcrumb segments +Breadcrumb.defineProperty(Breadcrumb, "path", {default = {}, type = "table", canTriggerRender = true}) +---@property separator > string Character(s) separating path segments +Breadcrumb.defineProperty(Breadcrumb, "separator", {default = " > ", type = "string", canTriggerRender = true}) +---@property clickable true boolean Whether the segments are clickable +Breadcrumb.defineProperty(Breadcrumb, "clickable", {default = true, type = "boolean"}) +---@property autoSize false boolean Whether to resize the element width automatically based on text +Breadcrumb.defineProperty(Breadcrumb, "autoSize", {default = true, type = "boolean"}) + +Breadcrumb.defineEvent(Breadcrumb, "mouse_click") +Breadcrumb.defineEvent(Breadcrumb, "mouse_up") + +--- @shortDescription Creates a new Breadcrumb instance +--- @return table self +function Breadcrumb.new() + local self = setmetatable({}, Breadcrumb):__init() + self.class = Breadcrumb + self.set("z", 5) + self.set("height", 1) + self.set("backgroundEnabled", false) + return self +end + +--- @shortDescription Initializes the Breadcrumb instance +--- @param props table +--- @param basalt table +function Breadcrumb:init(props, basalt) + VisualElement.init(self, props, basalt) + self.set("type", "Breadcrumb") +end + +--- @shortDescription Handles mouse click events +--- @param button number +--- @param x number +--- @param y number +--- @return boolean handled +function Breadcrumb:mouse_click(button, x, y) + if not self.get("clickable") then return false end + if VisualElement.mouse_click(self, button, x, y) then + local path = self.get("path") + local separator = self.get("separator") + + local cursorX = 1 + for i, segment in ipairs(path) do + local segLen = #segment + if x >= cursorX and x < cursorX + segLen then + self:fireEvent("select", + i, + {table.unpack(path, 1, i)} + ) + return true + end + cursorX = cursorX + segLen + if i < #path then + cursorX = cursorX + #separator + end + end + end + return false +end + +--- Registers a callback for the select event +--- @shortDescription Registers a callback for the select event +--- @param callback function The callback function to register +--- @return Breadcrumb self The Breadcrumb instance +--- @usage breadcrumb:onSelect(function(segmentIndex, path) print("Navigated to segment:", segmentIndex, path) end) +function Breadcrumb:onSelect(callback) + self:registerCallback("select", callback) + return self +end + +--- @shortDescription Renders the breadcrumb trail +--- @protected +function Breadcrumb:render() + local path = self.get("path") + local separator = self.get("separator") + local fg = self.get("foreground") + local clickable = self.get("clickable") + local width = self.get("width") + + local fullText = "" + for i, segment in ipairs(path) do + fullText = fullText .. segment + if i < #path then + fullText = fullText .. separator + end + end + + if self.get("autoSize") then + self.set("width", #fullText) + else + if #fullText > width then + local ellipsis = "... > " + local maxTextLen = width - #ellipsis + if maxTextLen > 0 then + fullText = ellipsis .. fullText:sub(-maxTextLen) + else + fullText = ellipsis:sub(1, width) + end + end + end + + local cursorX = 1 + local color + for word in fullText:gmatch("[^"..separator.."]+") do + color = fg + self:textFg(cursorX, 1, word, color) + cursorX = cursorX + #word + local sepStart = fullText:find(separator, cursorX, true) + if sepStart then + self:textFg(cursorX, 1, separator, clickable and colors.gray or colors.lightGray) + cursorX = cursorX + #separator + end + end +end + +return Breadcrumb \ No newline at end of file diff --git a/src/elements/ContextMenu.lua b/src/elements/ContextMenu.lua new file mode 100644 index 0000000..6721d3c --- /dev/null +++ b/src/elements/ContextMenu.lua @@ -0,0 +1,363 @@ +local elementManager = require("elementManager") +local VisualElement = require("elements/VisualElement") +local Container = elementManager.getElement("Container") +local tHex = require("libraries/colorHex") +---@configDescription A ContextMenu element that displays a menu with items and submenus. + +--- The ContextMenu displays a list of clickable items with optional submenus +--- @run [[ +--- local basalt = require("basalt") +--- +--- local main = basalt.getMainFrame() +--- +--- -- Create a label that shows the selected action +--- local statusLabel = main:addLabel({ +--- x = 2, +--- y = 2, +--- text = "Right-click anywhere!", +--- foreground = colors.yellow +--- }) +--- +--- -- Create a ContextMenu +--- local contextMenu = main:addContextMenu({ +--- x = 10, +--- y = 5, +--- background = colors.black, +--- foreground = colors.white, +--- }) +--- +--- contextMenu:setItems({ +--- { +--- label = "Copy", +--- onClick = function() +--- statusLabel:setText("Action: Copy") +--- end +--- }, +--- { +--- label = "Paste", +--- onClick = function() +--- statusLabel:setText("Action: Paste") +--- end +--- }, +--- { +--- label = "Delete", +--- background = colors.red, +--- foreground = colors.white, +--- onClick = function() +--- statusLabel:setText("Action: Delete") +--- end +--- }, +--- {label = "---", disabled = true}, +--- { +--- label = "More Options", +--- submenu = { +--- { +--- label = "Option 1", +--- onClick = function() +--- statusLabel:setText("Action: Option 1") +--- end +--- }, +--- { +--- label = "Option 2", +--- onClick = function() +--- statusLabel:setText("Action: Option 2") +--- end +--- }, +--- {label = "---", disabled = true}, +--- { +--- label = "Nested", +--- submenu = { +--- { +--- label = "Deep 1", +--- onClick = function() +--- statusLabel:setText("Action: Deep 1") +--- end +--- } +--- } +--- } +--- } +--- }, +--- {label = "---", disabled = true}, +--- { +--- label = "Exit", +--- onClick = function() +--- statusLabel:setText("Action: Exit") +--- end +--- } +--- }) +--- +--- -- Open menu on right-click anywhere +--- main:onClick(function(self, button, x, y) +--- if button == 2 then +--- contextMenu.set("x", x) +--- contextMenu.set("y", y) +--- contextMenu:open() +--- basalt.LOGGER.info("Context menu opened at (" .. x .. ", " .. y .. ")") +--- end +--- end) +--- +--- basalt.run() +--- ]] +---@class ContextMenu : Container +local ContextMenu = setmetatable({}, Container) +ContextMenu.__index = ContextMenu + +---@property items table {} List of menu items +ContextMenu.defineProperty(ContextMenu, "items", {default = {}, type = "table", canTriggerRender = true}) +---@property isOpen boolean false Whether the menu is currently open +ContextMenu.defineProperty(ContextMenu, "isOpen", {default = false, type = "boolean", canTriggerRender = true}) +---@property openSubmenu table nil Currently open submenu data +ContextMenu.defineProperty(ContextMenu, "openSubmenu", {default = nil, type = "table", allowNil = true}) +---@property itemHeight number 1 Height of each menu item +ContextMenu.defineProperty(ContextMenu, "itemHeight", {default = 1, type = "number", canTriggerRender = true}) + +ContextMenu.defineEvent(ContextMenu, "mouse_click") + +--- @shortDescription Creates a new ContextMenu instance +--- @return ContextMenu self The created instance +--- @private +function ContextMenu.new() + local self = setmetatable({}, ContextMenu):__init() + self.class = ContextMenu + self.set("width", 10) + self.set("height", 10) + self.set("visible", false) + return self +end + +--- @shortDescription Initializes the ContextMenu instance +--- @param props table The properties to initialize the element with +--- @param basalt table The basalt instance +--- @protected +function ContextMenu:init(props, basalt) + Container.init(self, props, basalt) + self.set("type", "ContextMenu") +end + +--- Sets the menu items +--- @shortDescription Sets the menu items from a table +--- @param items table Array of item definitions +--- @return ContextMenu self For method chaining +function ContextMenu:setItems(items) + self.set("items", items or {}) + self:calculateSize() + return self +end + +--- @shortDescription Calculates menu size based on items +--- @private +function ContextMenu:calculateSize() + local items = self.get("items") + local itemHeight = self.get("itemHeight") + + if #items == 0 then + self.set("width", 10) + self.set("height", 2) + return + end + + local maxWidth = 8 + for _, item in ipairs(items) do + if item.label then + local labelLen = #item.label + local itemWidth = labelLen + 3 + if item.submenu then + itemWidth = itemWidth + 1 -- " >" + end + if itemWidth > maxWidth then + maxWidth = itemWidth + end + end + end + + local height = #items * itemHeight + + self.set("width", maxWidth) + self.set("height", height) +end + +--- Opens the menu +--- @shortDescription Opens the context menu +--- @return ContextMenu self For method chaining +function ContextMenu:open() + self.set("isOpen", true) + self.set("visible", true) + self:updateRender() + self:dispatchEvent("opened") + return self +end + +--- Closes the menu and any submenus +--- @shortDescription Closes the context menu +--- @return ContextMenu self For method chaining +function ContextMenu:close() + self.set("isOpen", false) + self.set("visible", false) + + local openSubmenu = self.get("openSubmenu") + if openSubmenu and openSubmenu.menu then + openSubmenu.menu:close() + end + self.set("openSubmenu", nil) + + self:updateRender() + self:dispatchEvent("closed") + return self +end + +--- Closes the entire menu chain (parent and all submenus) +--- @shortDescription Closes the root menu and all child menus +--- @return ContextMenu self For method chaining +function ContextMenu:closeAll() + local root = self + while root.parentMenu do + root = root.parentMenu + end + + root:close() + return self +end + +--- @shortDescription Gets item at Y position +--- @param y number Relative Y position +--- @return number? index Item index or nil +--- @return table? item Item data or nil +--- @private +function ContextMenu:getItemAt(y) + local items = self.get("items") + local itemHeight = self.get("itemHeight") + + local index = math.floor((y - 1) / itemHeight) + 1 + + if index >= 1 and index <= #items then + return index, items[index] + end + + return nil, nil +end + +--- @shortDescription Creates a submenu +--- @private +function ContextMenu:createSubmenu(submenuItems, parentItem) + local submenu = self.parent:addContextMenu() + submenu:setItems(submenuItems) + + submenu.set("background", self.get("background")) + submenu.set("foreground", self.get("foreground")) + + submenu.parentMenu = self + + local parentX = self.get("x") + local parentY = self.get("y") + local parentWidth = self.get("width") + local itemHeight = self.get("itemHeight") + local itemIndex = parentItem._index or 1 + + submenu.set("x", parentX + parentWidth) + submenu.set("y", parentY + (itemIndex - 1) * itemHeight) + submenu.set("z", self.get("z") + 1) + + return submenu +end + +--- @shortDescription Handles mouse click events +--- @protected +function ContextMenu:mouse_click(button, x, y) + if not VisualElement.mouse_click(self, button, x, y) then + self:close() + return false + end + + local relX, relY = VisualElement.getRelativePosition(self, x, y) + local index, item = self:getItemAt(relY) + + if item then + if item.disabled then + return true + end + + if item.submenu then + local openSubmenu = self.get("openSubmenu") + if openSubmenu and openSubmenu.index == index then + openSubmenu.menu:close() + self.set("openSubmenu", nil) + else + if openSubmenu and openSubmenu.menu then + openSubmenu.menu:close() + end + + item._index = index + local submenu = self:createSubmenu(item.submenu, item) + submenu:open() + + self.set("openSubmenu", { + index = index, + menu = submenu + }) + end + return true + end + + if item.onClick then + item.onClick(item) + end + + self:closeAll() + return true + end + return true +end + +--- @shortDescription Renders the ContextMenu +--- @protected +function ContextMenu:render() + local items = self.get("items") + local width = self.get("width") + local height = self.get("height") + local itemHeight = self.get("itemHeight") + local menuBg = self.get("background") + local menuFg = self.get("foreground") + + for i, item in ipairs(items) do + local y = (i - 1) * itemHeight + 1 + local itemBg = item.background or menuBg + local itemFg = item.foreground or menuFg + local bgHex = tHex[itemBg] + local fgHex = tHex[itemFg] + + local spaces = string.rep(" ", width) + local bgColors = string.rep(bgHex, width) + local fgColors = string.rep(fgHex, width) + self:blit(1, y, spaces, fgColors, bgColors) + + local label = item.label or "" + if #label > width - 3 then + label = label:sub(1, width - 3) + end + + self:textFg(2, y, label, itemFg) + if item.submenu then + self:textFg(width - 1, y, ">", itemFg) + end + end + + if not self.get("childrenSorted") then + self:sortChildren() + end + if not self.get("childrenEventsSorted") then + for eventName in pairs(self._values.childrenEvents or {}) do + self:sortChildrenEvents(eventName) + end + end + + for _, child in ipairs(self.get("visibleChildren") or {}) do + if child == self then + error("CIRCULAR REFERENCE DETECTED!") + return + end + child:render() + child:postRender() + end +end + +return ContextMenu \ No newline at end of file diff --git a/src/elements/Dialog.lua b/src/elements/Dialog.lua new file mode 100644 index 0000000..ed8803d --- /dev/null +++ b/src/elements/Dialog.lua @@ -0,0 +1,298 @@ +local elementManager = require("elementManager") +local Frame = elementManager.getElement("Frame") + +---@configDescription A dialog overlay system with common presets (alert, confirm, prompt). + +--- A dialog overlay system that provides common dialog types such as alert, confirm, and prompt. +---@class Dialog : Frame +local Dialog = setmetatable({}, Frame) +Dialog.__index = Dialog + +---@property title string "" The dialog title +Dialog.defineProperty(Dialog, "title", {default = "", type = "string", canTriggerRender = true}) + +---@property primaryColor color lime Primary button color (OK, confirm actions) +Dialog.defineProperty(Dialog, "primaryColor", {default = colors.lime, type = "color"}) + +---@property secondaryColor color lightGray Secondary button color (Cancel, dismiss actions) +Dialog.defineProperty(Dialog, "secondaryColor", {default = colors.lightGray, type = "color"}) + +---@property buttonForeground color black Foreground color for buttons +Dialog.defineProperty(Dialog, "buttonForeground", {default = colors.black, type = "color"}) + +---@property modal boolean true If true, blocks all events outside the dialog +Dialog.defineProperty(Dialog, "modal", {default = true, type = "boolean"}) + +Dialog.defineEvent(Dialog, "mouse_click") +Dialog.defineEvent(Dialog, "close") + +--- Creates a new Dialog instance +--- @shortDescription Creates a new Dialog instance +--- @return Dialog self The newly created Dialog instance +--- @private +function Dialog.new() + local self = setmetatable({}, Dialog):__init() + self.class = Dialog + self.set("z", 100) + self.set("width", 30) + self.set("height", 10) + self.set("background", colors.gray) + self.set("foreground", colors.white) + self.set("borderColor", colors.cyan) + return self +end + +--- Initializes a Dialog instance +--- @shortDescription Initializes a Dialog instance +--- @param props table Initial properties +--- @param basalt table The basalt instance +--- @return Dialog self The initialized Dialog instance +--- @private +function Dialog:init(props, basalt) + Frame.init(self, props, basalt) + self:addBorder({left = true, right = true, top = true, bottom = true}) + self.set("type", "Dialog") + return self +end + +--- Shows the dialog +--- @shortDescription Shows the dialog +--- @return Dialog self The Dialog instance +function Dialog:show() + self:center() + self.set("visible", true) + -- Auto-focus when modal + if self.get("modal") then + self:setFocused(true) + end + return self +end + +--- Closes the dialog +--- @shortDescription Closes the dialog +--- @return Dialog self The Dialog instance +function Dialog:close() + self.set("visible", false) + self:fireEvent("close") + return self +end + +--- Creates a simple alert dialog +--- @shortDescription Creates a simple alert dialog +--- @param title string The alert title +--- @param message string The alert message +--- @param callback? function Callback when OK is clicked +--- @return Dialog self The Dialog instance +function Dialog:alert(title, message, callback) + self:clear() + self.set("title", title) + self.set("height", 8) + + self:addLabel({ + text = message, + x = 2, y = 3, + width = self.get("width") - 3, + height = 3, + foreground = colors.white + }) + + local btnWidth = 10 + local btnX = math.floor((self.get("width") - btnWidth) / 2) + 1 + + self:addButton({ + text = "OK", + x = btnX, + y = self.get("height") - 2, + width = btnWidth, + height = 1, + background = self.get("primaryColor"), + foreground = self.get("buttonForeground") + }):onClick(function() + if callback then callback() end + self:close() + end) + + return self:show() +end + +--- Creates a confirm dialog +--- @shortDescription Creates a confirm dialog +--- @param title string The dialog title +--- @param message string The confirmation message +--- @param callback function Callback (receives boolean result) +--- @return Dialog self The Dialog instance +function Dialog:confirm(title, message, callback) + self:clear() + self.set("title", title) + self.set("height", 8) + + self:addLabel({ + text = message, + x = 2, y = 3, + width = self.get("width") - 3, + height = 3, + foreground = colors.white + }) + + local btnWidth = 10 + local spacing = 2 + local totalWidth = btnWidth * 2 + spacing + local startX = math.floor((self.get("width") - totalWidth) / 2) + 1 + + self:addButton({ + text = "Cancel", + x = startX, + y = self.get("height") - 2, + width = btnWidth, + height = 1, + background = self.get("secondaryColor"), + foreground = self.get("buttonForeground") + }):onClick(function() + if callback then callback(false) end + self:close() + end) + + self:addButton({ + text = "OK", + x = startX + btnWidth + spacing, + y = self.get("height") - 2, + width = btnWidth, + height = 1, + background = self.get("primaryColor"), + foreground = self.get("buttonForeground") + }):onClick(function() + if callback then callback(true) end + self:close() + end) + + return self:show() +end + +--- Creates a prompt dialog with input +--- @shortDescription Creates a prompt dialog with input +--- @param title string The dialog title +--- @param message string The prompt message +--- @param default? string Default input value +--- @param callback? function Callback (receives input text or nil if cancelled) +--- @return Dialog self The Dialog instance +function Dialog:prompt(title, message, default, callback) + self:clear() + self.set("title", title) + self.set("height", 11) + + self:addLabel({ + text = message, + x = 2, y = 3, + foreground = colors.white + }) + + local input = self:addInput({ + x = 2, y = 5, + width = self.get("width") - 3, + height = 1, + defaultText = default or "", + background = colors.white, + foreground = colors.black + }) + + local btnWidth = 10 + local spacing = 2 + local totalWidth = btnWidth * 2 + spacing + local startX = math.floor((self.get("width") - totalWidth) / 2) + 1 + + self:addButton({ + text = "Cancel", + x = startX, + y = self.get("height") - 2, + width = btnWidth, + height = 1, + background = self.get("secondaryColor"), + foreground = self.get("buttonForeground") + }):onClick(function() + if callback then callback(nil) end + self:close() + end) + + self:addButton({ + text = "OK", + x = startX + btnWidth + spacing, + y = self.get("height") - 2, + width = btnWidth, + height = 1, + background = self.get("primaryColor"), + foreground = self.get("buttonForeground") + }):onClick(function() + if callback then callback(input.get("text") or "") end + self:close() + end) + + return self:show() +end + +--- Renders the dialog +--- @shortDescription Renders the dialog +--- @protected +function Dialog:render() + Frame.render(self) + + local title = self.get("title") + if title ~= "" then + local width = self.get("width") + local titleText = title:sub(1, width - 4) + self:textFg(2, 2, titleText, colors.white) + end +end + +--- Handles mouse click events +--- @shortDescription Handles mouse click events +--- @protected +function Dialog:mouse_click(button, x, y) + if self.get("modal") then + if self:isInBounds(x, y) then + return Frame.mouse_click(self, button, x, y) + end + return true + end + return Frame.mouse_click(self, button, x, y) +end + +--- Handles mouse drag events +--- @shortDescription Handles mouse drag events +--- @protected +function Dialog:mouse_drag(button, x, y) + if self.get("modal") then + if self:isInBounds(x, y) then + return Frame.mouse_drag and Frame.mouse_drag(self, button, x, y) or false + end + return true + end + return Frame.mouse_drag and Frame.mouse_drag(self, button, x, y) or false +end + +--- Handles mouse up events +--- @shortDescription Handles mouse up events +--- @protected +function Dialog:mouse_up(button, x, y) + if self.get("modal") then + if self:isInBounds(x, y) then + return Frame.mouse_up and Frame.mouse_up(self, button, x, y) or false + end + return true + end + return Frame.mouse_up and Frame.mouse_up(self, button, x, y) or false +end + +--- Handles mouse scroll events +--- @shortDescription Handles mouse scroll events +--- @protected +function Dialog:mouse_scroll(direction, x, y) + if self.get("modal") then + if self:isInBounds(x, y) then + return Frame.mouse_scroll and Frame.mouse_scroll(self, direction, x, y) or false + end + return true + end + return Frame.mouse_scroll and Frame.mouse_scroll(self, direction, x, y) or false +end + +return Dialog \ No newline at end of file diff --git a/src/elements/Toast.lua b/src/elements/Toast.lua new file mode 100644 index 0000000..636ed92 --- /dev/null +++ b/src/elements/Toast.lua @@ -0,0 +1,234 @@ +local elementManager = require("elementManager") +local VisualElement = elementManager.getElement("VisualElement") +local tHex = require("libraries/colorHex") + +---@configDescription A toast notification element that displays temporary messages. + +--- A toast notification element that displays temporary messages with optional icons and auto-hide functionality. +--- The element is always visible but only renders content when a message is shown. +---@class Toast : VisualElement +local Toast = setmetatable({}, VisualElement) +Toast.__index = Toast + +---@property title string "" The title text of the toast +Toast.defineProperty(Toast, "title", {default = "", type = "string", canTriggerRender = true}) + +---@property message string "" The message text of the toast +Toast.defineProperty(Toast, "message", {default = "", type = "string", canTriggerRender = true}) + +---@property duration number 3 Duration in seconds before the toast auto-hides +Toast.defineProperty(Toast, "duration", {default = 3, type = "number"}) + +---@property toastType string "default" Type of toast: default, success, error, warning, info +Toast.defineProperty(Toast, "toastType", {default = "default", type = "string", canTriggerRender = true}) + +---@property autoHide boolean true Whether the toast should automatically hide after duration +Toast.defineProperty(Toast, "autoHide", {default = true, type = "boolean"}) + +---@property active boolean false Whether the toast is currently showing a message +Toast.defineProperty(Toast, "active", {default = false, type = "boolean", canTriggerRender = true}) + +---@property colorMap table Map of toast types to their colors +Toast.defineProperty(Toast, "colorMap", { + default = { + success = colors.green, + error = colors.red, + warning = colors.orange, + info = colors.lightBlue, + default = colors.gray + }, + type = "table" +}) + +Toast.defineEvent(Toast, "timer") + +--- Creates a new Toast instance +--- @shortDescription Creates a new Toast instance +--- @return Toast self The newly created Toast instance +--- @private +function Toast.new() + local self = setmetatable({}, Toast):__init() + self.class = Toast + self.set("width", 30) + self.set("height", 3) + self.set("z", 100) -- High z-index so it appears on top + return self +end + +--- Initializes a Toast instance +--- @shortDescription Initializes a Toast instance +--- @param props table Initial properties +--- @param basalt table The basalt instance +--- @return Toast self The initialized Toast instance +--- @private +function Toast:init(props, basalt) + VisualElement.init(self, props, basalt) + return self +end + +--- Shows a toast message +--- @shortDescription Shows a toast message +--- @param titleOrMessage string The title (if message provided) or the message (if no message) +--- @param messageOrDuration? string|number The message (if string) or duration (if number) +--- @param duration? number Duration in seconds +--- @return Toast self The Toast instance +function Toast:show(titleOrMessage, messageOrDuration, duration) + local title, message, dur + if type(messageOrDuration) == "string" then + title = titleOrMessage + message = messageOrDuration + dur = duration or self.get("duration") + elseif type(messageOrDuration) == "number" then + title = "" + message = titleOrMessage + dur = messageOrDuration + else + title = "" + message = titleOrMessage + dur = self.get("duration") + end + + self.set("title", title) + self.set("message", message) + self.set("active", true) + + if self._hideTimerId then + os.cancelTimer(self._hideTimerId) + self._hideTimerId = nil + end + + if self.get("autoHide") and dur > 0 then + self._hideTimerId = os.startTimer(dur) + end + + return self +end + +--- Hides the toast +--- @shortDescription Hides the toast +--- @return Toast self The Toast instance +function Toast:hide() + self.set("active", false) + self.set("title", "") + self.set("message", "") + + if self._hideTimerId then + os.cancelTimer(self._hideTimerId) + self._hideTimerId = nil + end + return self +end + +--- Shows a success toast +--- @shortDescription Shows a success toast +--- @param titleOrMessage string The title or message +--- @param messageOrDuration? string|number The message or duration +--- @param duration? number Duration in seconds +--- @return Toast self The Toast instance +function Toast:success(titleOrMessage, messageOrDuration, duration) + self.set("toastType", "success") + return self:show(titleOrMessage, messageOrDuration, duration) +end + +--- Shows an error toast +--- @shortDescription Shows an error toast +--- @param titleOrMessage string The title or message +--- @param messageOrDuration? string|number The message or duration +--- @param duration? number Duration in seconds +--- @return Toast self The Toast instance +function Toast:error(titleOrMessage, messageOrDuration, duration) + self.set("toastType", "error") + return self:show(titleOrMessage, messageOrDuration, duration) +end + +--- Shows a warning toast +--- @shortDescription Shows a warning toast +--- @param titleOrMessage string The title or message +--- @param messageOrDuration? string|number The message or duration +--- @param duration? number Duration in seconds +--- @return Toast self The Toast instance +function Toast:warning(titleOrMessage, messageOrDuration, duration) + self.set("toastType", "warning") + return self:show(titleOrMessage, messageOrDuration, duration) +end + +--- Shows an info toast +--- @shortDescription Shows an info toast +--- @param titleOrMessage string The title or message +--- @param messageOrDuration? string|number The message or duration +--- @param duration? number Duration in seconds +--- @return Toast self The Toast instance +function Toast:info(titleOrMessage, messageOrDuration, duration) + self.set("toastType", "info") + return self:show(titleOrMessage, messageOrDuration, duration) +end + +--- @shortDescription Dispatches events to the Toast instance +--- @protected +function Toast:dispatchEvent(event, ...) + VisualElement.dispatchEvent(self, event, ...) + if event == "timer" then + local timerId = select(1, ...) + if timerId == self._hideTimerId then + self:hide() + end + end +end + +--- Renders the toast +--- @shortDescription Renders the toast +--- @protected +function Toast:render() + VisualElement.render(self) + if not self.get("active") then + return + end + + local width = self.get("width") + local height = self.get("height") + local title = self.getResolved("title") + local message = self.getResolved("message") + local toastType = self.getResolved("toastType") + local colorMap = self.getResolved("colorMap") + + local typeColor = colorMap[toastType] or colorMap.default + local fg = self.getResolved("foreground") + + local startX = 1 + + local currentY = 1 + if title ~= "" then + local titleText = title:sub(1, width - startX + 1) + self:textFg(startX, currentY, titleText, typeColor) + currentY = currentY + 1 + end + + if message ~= "" and currentY <= height then + local availableWidth = width - startX + 1 + local words = {} + for word in message:gmatch("%S+") do + table.insert(words, word) + end + + local line = "" + for _, word in ipairs(words) do + if #line + #word + 1 > availableWidth then + if currentY <= height then + self:textFg(startX, currentY, line, fg) + currentY = currentY + 1 + line = word + else + break + end + else + line = line == "" and word or line .. " " .. word + end + end + + if line ~= "" and currentY <= height then + self:textFg(startX, currentY, line, fg) + end + end +end + +return Toast