Add Breadcrumb, ContextMenu, Dialog, and Toast elements with event handling and rendering capabilities

- Implemented Breadcrumb element for navigation with selectable segments.
- Created ContextMenu element for displaying clickable items and submenus.
- Developed Dialog element for alert, confirm, and prompt dialogs with modal support.
- Introduced Toast element for temporary message notifications with auto-hide functionality.
This commit is contained in:
Robert Jelic
2025-10-30 14:12:54 +01:00
parent 196cf93f68
commit a967cde115
6 changed files with 1492 additions and 6 deletions

5
.gitignore vendored
View File

@@ -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

471
src/elements/Accordion.lua Normal file
View File

@@ -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

125
src/elements/Breadcrumb.lua Normal file
View File

@@ -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

View File

@@ -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

298
src/elements/Dialog.lua Normal file
View File

@@ -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

234
src/elements/Toast.lua Normal file
View File

@@ -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