diff --git a/src/elements/Menu.lua b/src/elements/Menu.lua index d0fa98d..26e12a7 100644 --- a/src/elements/Menu.lua +++ b/src/elements/Menu.lua @@ -8,20 +8,21 @@ local tHex = require("libraries/colorHex") local Menu = setmetatable({}, List) Menu.__index = Menu ----@tableType ItemTable ----@tableField text string The display text for the item ----@tableField callback function Function called when selected ----@tableField fg color Normal text color ----@tableField bg color Normal background color ----@tableField selectedFg color Text color when selected ----@tableField selectedBg color Background when selected - ---@property separatorColor color gray The color used for separator items in the menu Menu.defineProperty(Menu, "separatorColor", {default = colors.gray, type = "color"}) ---@property spacing number 0 The number of spaces between menu items Menu.defineProperty(Menu, "spacing", {default = 1, type = "number", canTriggerRender = true}) +---@property openDropdown table nil Currently open dropdown data {index, items, x, y, width, height} +Menu.defineProperty(Menu, "openDropdown", {default = nil, type = "table", allowNil = true, canTriggerRender = true}) + +---@property dropdownBackground color black Background color for dropdown menus +Menu.defineProperty(Menu, "dropdownBackground", {default = colors.black, type = "color", canTriggerRender = true}) + +---@property dropdownForeground color white Foreground color for dropdown menus +Menu.defineProperty(Menu, "dropdownForeground", {default = colors.white, type = "color", canTriggerRender = true}) + ---@property horizontalOffset number 0 Current horizontal scroll offset Menu.defineProperty(Menu, "horizontalOffset", { default = 0, @@ -36,6 +37,25 @@ Menu.defineProperty(Menu, "horizontalOffset", { ---@property maxWidth number nil Maximum width before scrolling is enabled (nil = auto-size to items) Menu.defineProperty(Menu, "maxWidth", {default = nil, type = "number", canTriggerRender = true}) +---@tableType ItemTable +---@tableField text string The display text for the item +---@tableField callback function Function called when selected +---@tableField fg color Normal text color +---@tableField bg color Normal background color +---@tableField selectedFg color Text color when selected +---@tableField selectedBg color Background when selected +---@tableField dropdown table Array of dropdown items + +local entrySchema = { + text = { type = "string", default = "Entry" }, + bg = { type = "number", default = nil }, + fg = { type = "number", default = nil }, + selectedBg = { type = "number", default = nil }, + selectedFg = { type = "number", default = nil }, + callback = { type = "function", default = nil }, + dropdown = { type = "table", default = nil }, +} + --- Creates a new Menu instance --- @shortDescription Creates a new Menu instance --- @return Menu self The newly created Menu instance @@ -45,7 +65,7 @@ function Menu.new() self.class = Menu self.set("width", 30) self.set("height", 1) - self.set("background", colors.gray) + self.set("z", 8) return self end @@ -56,6 +76,7 @@ end --- @protected function Menu:init(props, basalt) List.init(self, props, basalt) + self._entrySchema = entrySchema self.set("type", "Menu") self:observe("items", function() @@ -175,12 +196,67 @@ function Menu:render() end end end + + local openDropdown = self.getResolved("openDropdown") + if openDropdown then + self:renderDropdown(openDropdown) + end +end + +--- Renders the dropdown menu +--- @shortDescription Renders dropdown overlay +--- @param dropdown table Dropdown data +--- @protected +function Menu:renderDropdown(dropdown) + local dropdownBg = self.getResolved("dropdownBackground") + local dropdownFg = self.getResolved("dropdownForeground") + + for i, item in ipairs(dropdown.items) do + local y = dropdown.y + i - 1 + local label = item.text or item.label or "" + + local isSeparator = label == "---" + + local bgHex = tHex[item.background or dropdownBg] + local fgHex = tHex[item.foreground or dropdownFg] + local spaces = string.rep(" ", dropdown.width) + + self:blit(dropdown.x, y, spaces, + string.rep(fgHex, dropdown.width), + string.rep(bgHex, dropdown.width)) + + if isSeparator then + local separator = string.rep("-", dropdown.width) + self:blit(dropdown.x, y, separator, + string.rep(tHex[colors.gray], dropdown.width), + string.rep(bgHex, dropdown.width)) + else + if #label > dropdown.width - 2 then + label = label:sub(1, dropdown.width - 2) + end + self:textFg(dropdown.x + 1, y, label, item.foreground or dropdownFg) + end + end end --- @shortDescription Handles mouse click events and item selection --- @protected function Menu:mouse_click(button, x, y) - if not VisualElement.mouse_click(self, button, x, y) then return false end + local openDropdown = self.getResolved("openDropdown") + if openDropdown then + local relX, relY = self:getRelativePosition(x, y) + + if self:isInsideDropdown(relX, relY, openDropdown) then + return self:handleDropdownClick(relX, relY, openDropdown) + else + self:hideDropdown() + end + end + + if not VisualElement.mouse_click(self, button, x, y) then + return false + end + if(self.getResolved("selectable") == false) then return false end local relX = select(1, self:getRelativePosition(x, y)) local offset = self.getResolved("horizontalOffset") @@ -200,6 +276,11 @@ function Menu:mouse_click(button, x, y) items[i] = item end + if item.dropdown and #item.dropdown > 0 then + self:showDropdown(i, item, currentX - offset) + return true + end + if not self.getResolved("multiSelection") then for _, otherItem in ipairs(items) do if type(otherItem) == "table" then @@ -240,4 +321,84 @@ function Menu:mouse_scroll(direction, x, y) return false end +--- Shows a dropdown menu for a specific item +--- @shortDescription Shows dropdown menu +--- @param index number The item index +--- @param item table The menu item +--- @param itemX number The X position of the item +function Menu:showDropdown(index, item, itemX) + local dropdown = item.dropdown + if not dropdown or #dropdown == 0 then return end + + local maxWidth = 8 + for _, dropItem in ipairs(dropdown) do + local label = dropItem.text or dropItem.label or "" + if #label + 2 > maxWidth then + maxWidth = #label + 2 + end + end + + local height = #dropdown + local menuHeight = self.getResolved("height") + + self.set("openDropdown", { + index = index, + items = dropdown, + x = itemX, + y = menuHeight + 1, + width = maxWidth, + height = height + }) + + self:updateRender() +end + +--- Closes the currently open dropdown +--- @shortDescription Closes dropdown menu +function Menu:hideDropdown() + self.set("openDropdown", nil) + self:updateRender() +end + +--- Checks if a position is inside the dropdown +--- @shortDescription Checks if position is in dropdown +--- @param relX number Relative X position +--- @param relY number Relative Y position +--- @param dropdown table Dropdown data +--- @return boolean inside Whether position is inside dropdown +function Menu:isInsideDropdown(relX, relY, dropdown) + return relX >= dropdown.x and + relX < dropdown.x + dropdown.width and + relY >= dropdown.y and + relY < dropdown.y + dropdown.height +end + +--- Handles click inside dropdown +--- @shortDescription Handles dropdown click +--- @param relX number Relative X position +--- @param relY number Relative Y position +--- @param dropdown table Dropdown data +--- @return boolean handled Whether click was handled +function Menu:handleDropdownClick(relX, relY, dropdown) + local itemIndex = relY - dropdown.y + 1 + + if itemIndex >= 1 and itemIndex <= #dropdown.items then + local item = dropdown.items[itemIndex] + + if item.text == "---" or item.label == "---" or item.disabled then + return true + end + + if item.callback then + item.callback(self, item) + elseif item.onClick then + item.onClick(self, item) + end + + self:hideDropdown() + return true + end + return false +end + return Menu