Files
Basalt2/src/elements/ContextMenu.lua
Robert Jelic a967cde115 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.
2025-10-30 14:12:54 +01:00

363 lines
10 KiB
Lua

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