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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
471
src/elements/Accordion.lua
Normal 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
125
src/elements/Breadcrumb.lua
Normal 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
|
||||
363
src/elements/ContextMenu.lua
Normal file
363
src/elements/ContextMenu.lua
Normal 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
298
src/elements/Dialog.lua
Normal 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
234
src/elements/Toast.lua
Normal 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
|
||||
Reference in New Issue
Block a user