fpv/source/libraries/noble/modules/Noble.Menu.lua

571 lines
23 KiB
Lua
Raw Normal View History

2024-06-01 16:52:11 +03:00
--- An extended implementation of `playdate.ui.gridview`, meant for 1-dimensional, single-screen text menus.
-- @module Noble.Menu
--
Noble.Menu = {}
--- Setup
-- @section setup
--- Create a new menu object.
-- @bool[opt=true] __activate @{activate|Activate} this menu upon creation.
-- @param[opt=Noble.Text.ALIGN_LEFT] __alignment The text alignment of menu items.
-- @bool[opt=false] __localized If true, menu item names are localization keys rather than display names.
-- @param[opt=Graphics.kColorBlack] __color The color of menu item text. The selected highlight will be the inverse color.
-- @int[opt=2] __padding Cell padding for menu items.
-- @int[opt] __horizontalPadding Use this to override horizontal padding, useful for certain fonts. If nil, uses __padding.
-- @int[opt=2] __margin Spacing between menu items.
-- @param[opt=Noble.Text.getCurrentFont()] __font If nil, uses current set font.
-- @int[opt=__font:getHeight()/4] __selectedCornerRadius Sets rounded corners for a selected menu item.
-- @int[opt=1] __selectedOutlineThickness Sets the outline thickness for selected items.
-- @return `menu`, a new menu item.
-- @usage
-- local menu = Noble.Menu.new(
-- true,
-- Noble.Text.ALIGN_CENTER,
-- false,
-- Graphics.kColorWhite,
-- 4, 6,
-- Noble.Text.large,
-- nil, 3
-- )
-- menu:addItem("Play Game", function() TitleScreen:playGame() end)
-- menu:addItem("Options", function() Noble.transition(OptionsScreen) end)
-- menu:addItem("Credits", function() Noble.transition(CreditsScreen) end)
-- @see addItem
function Noble.Menu.new(__activate, __alignment, __localized, __color, __padding, __horizontalPadding, __margin, __font, __selectedCornerRadius, __selectedOutlineThickness)
-- Prep for creating the gridview object
local paddingLocal = __padding or 2
local fontLocal = __font or Noble.Text.getCurrentFont()
local textHeightLocal = fontLocal:getHeight()
-- Create gridview object
local menu = UI.gridview.new(0, textHeightLocal + paddingLocal)
--- Properties
--@section properties
menu.alignment = __alignment or Noble.Text.ALIGN_LEFT
--- @bool[opt=false] _ Indicates whether this menu's item names are treated as localization keys.
menu.localized = Utilities.handleOptionalBoolean(__localized, false)
menu.textHeight = textHeightLocal
menu.padding = paddingLocal
menu.horizontalPadding = __horizontalPadding or menu.padding
menu.margin = __margin or 2
menu.font = fontLocal
menu.selectedCornerRadius = __selectedCornerRadius or textHeightLocal/4
menu.selectedOutlineThickness = __selectedOutlineThickness or 1
-- Local cleanup. We don't need these anymore.
paddingLocal = nil
fontLocal = nil
textHeightLocal = nil
-- Colors
menu.color = __color or Graphics.kColorBlack -- TO-DO allow for copy fill mode instead of color.
menu.fillMode = Graphics.kDrawModeFillBlack
menu.otherColor = Graphics.kColorWhite
menu.otherFillMode = Graphics.kDrawModeFillWhite
if (menu.color == Graphics.kColorWhite) then
menu.fillMode = Graphics.kDrawModeFillWhite
menu.otherColor = Graphics.kColorBlack
menu.otherFillMode = Graphics.kDrawModeFillBlack
end
-- Gridview properties
menu:setNumberOfColumns(1)
menu:setCellPadding(0, 0, 0, 0)
menu.changeRowOnColumnWrap = false
--- Tables
--@section tables
--- A string "array" of menu item strings/keys.
-- <strong>You cannot add or remove menu items by modifying this table</strong>.
-- It is meant as a <strong>read-only</strong> table, provided for convenience when iterating, etc. Modifying its values may break other methods.
-- @usage
-- for i = 1, #menu.itemNames, 1 do
-- menu.clickHandlers[menu.itemNames[i]] = nil -- Clears all click handlers, for some reason.
-- end
-- @see addItem
menu.itemNames = {}
-- This is an internal table. Modifying its may break other methods.
menu.displayNames = {}
-- This is an internal table. Modifying its may break other methods.
menu.displayNamesAreLocalized = {}
--- A table of functions associated with menu items. Items are a defined when calling @{addItem|addItem}, but their associated functions may be modified afterward.
--
-- <strong>You cannot add or remove menu items by modifying this table</strong>.
-- @usage
-- local menu = Noble.Menu.new(true)
-- menu.addItem("Play Game")
-- menu.addItem("Options")
--
-- menu.clickHandlers["Play Game"] = function() TitleScreen:playGame() end
-- menu.clickHandlers["Options"] = function() Noble.transition(OptionsScreen) end
-- @usage
-- local menu = Noble.Menu.new(true)
-- menu.addItem("Play Game")
-- menu.addItem("Options")
--
-- menu.clickHandlers = {
-- ["Play Game"] = function TitleScreen:playGame() end,
-- ["Options"] = function() Noble.transition(OptionsScreen) end
-- }
-- @see addItem
-- @see removeItem
menu.clickHandlers = {}
--- A key/value table of menu item indices.
--
-- This is meant as a <strong>read-only</strong> table, provided for convenience. Modifying its values will break other methods.
-- @usage
-- menu.itemPositions["Play Game"] -- 1
-- menu.itemPositions["Options"] -- 2
menu.itemPositions = {}
--- A key/value table of pixel widths for each menu item, based on its text. Useful for animation, layout, etc.
--
-- This is meant as a <strong>read-only</strong> table, provided for convenience. Modifying its values will break other methods.
-- @usage local playGameMenuItemWidth = menu.itemWidths["Play Game"]
menu.itemWidths = {}
--- Properties
-- @section properties
--- @int _
-- The current menu item's index.
--
-- This is meant as a <strong>read-only</strong> value. Do not modify it directly.
-- @see select
menu.currentItemNumber = 1
--- @string _
-- The current menu item's name.
--
-- This is meant as a <strong>read-only</strong> value. Do not modify it directly.
-- @see select
menu.currentItemName = menu.itemNames[1]
--- @int _
-- The width of the widest menu item plus the menu's horizontal padding.
--
-- This is meant as a <strong>read-only</strong> value. Do not modify it directly.
menu.width = 0
--- Setup
-- @section setup
--- Adds a item to this menu.
-- @string __nameOrKey The name of this menu item. It can be a display name or a localization key. <strong>Must be unique.</strong>
-- @tparam[opt] function __clickHandler The function that runs when this menu item is "clicked."
-- @int[opt] __position Insert the item at a specific position. If not set, adds to the end of the list.
-- @string[opt] __displayName You can create an optional, separate display name for this item. You can add or change this at runtime via @{setItemDisplayName|setItemDisplayName}.
-- @bool[opt=false] __displayNameIsALocalizationKey If true, will treat the `__displayName` as a localization key. This is separate from this menu's @{localized|localized} value.
-- @see new
-- @see removeItem
-- @see setItemDisplayName
function menu:addItem(__nameOrKey, __clickHandler, __position, __displayName, __displayNameIsALocalizationKey)
local clickHandler = __clickHandler or function () print("Menu item \"" .. __nameOrKey .. "\" clicked!") end
if (__position ~= nil) then
if (__position <= 0 or __position > #self.itemNames) then error("BONK: Menu item out of range.", 3) return end
table.insert(self.itemNames, __position, __nameOrKey)
for key, value in pairs(self.itemPositions) do
if (value >= __position) then
self.itemPositions[key] = self.itemPositions[key] + 1
end
end
self.itemPositions[__nameOrKey] = __position
else
table.insert(self.itemNames, __nameOrKey)
self.itemPositions[__nameOrKey] = #self.itemNames
end
self.clickHandlers[__nameOrKey] = clickHandler
-- Item name
local nameOrKey
if (self.localized) then
nameOrKey = Graphics.getLocalizedText(__nameOrKey)
else
nameOrKey = __nameOrKey
end
-- Display name
local displayName = nil
if (__displayName ~= nil) then
if (__displayNameIsALocalizationKey == true) then
displayName = Graphics.getLocalizedText(__displayName)
else
displayName = __displayName
end
self.displayNames[__nameOrKey] = displayName
end
if (displayName == nil) then
self:updateWidths(__nameOrKey, nameOrKey)
else
self:updateWidths(__nameOrKey, displayName)
end
self:setNumberOfRows(#self.itemNames)
end
-- Internal method.
function menu:updateWidths(__nameOrKey, __string)
if (__string == nil) then
__string = __nameOrKey
end
-- Item width
self.itemWidths[__nameOrKey] = self.font:getTextWidth(__string)
-- Menu width
local width = 0
for _, value in pairs(self.itemWidths) do
if value > width then width = value end
end
self.width = width + (self.horizontalPadding * 2) + (self.selectedOutlineThickness * 2)
end
--- Removes a item from this menu.
-- @tparam[opt=#menu.itemNames] int|string __menuItem The menu item to remove. You can enter either the item's name/key or it's position. If left blank, removes the last item.
-- @see addItem
function menu:removeItem(__menuItem)
local itemString = nil
local itemPosition = nil
if (__menuItem == nil) then
__menuItem = #self.itemNames
end
if (type(__menuItem) == "number") then
if (__menuItem <= 0 or __menuItem > #self.itemNames) then error("BONK: Menu item out of range.", 3) return end
itemString = self.itemNames[__menuItem]
itemPosition = __menuItem
elseif (type(__menuItem) == "string") then
itemString = __menuItem
itemPosition = self.itemPositions[__menuItem]
if (itemPosition == nil) then error("BONK: Menu item not found.", 3) return end
end
for key, value in pairs(self.itemPositions) do
if (value > itemPosition) then
self.itemPositions[key] = self.itemPositions[key] - 1
end
end
table.remove(self.itemNames, itemPosition)
self.itemPositions[itemString] = nil
self.clickHandlers[itemString] = nil
self.itemWidths[itemString] = nil
self.displayNames[itemString] = nil
self.displayNamesAreLocalized[itemString] = nil
-- In case the current item is selected.
if (self.currentItemNumber == itemPosition and self.currentItemNumber ~= 1) then
self:select(self.currentItemNumber - 1, true)
if (self.isActive() == false) then
self:setSelectedRow(0)
end
end
self:setNumberOfRows(#self.itemNames)
-- Update width
local width = 0
for _, value in pairs(self.itemWidths) do
if value > width then width = value end
end
self.width = width + (self.horizontalPadding * 2) + (self.selectedOutlineThickness * 2)
end
--
local active = Utilities.handleOptionalBoolean(__activate, true)
if (active) then
menu.currentItemNumber = 1
menu.currentItemName = menu.itemNames[1]
menu:setSelectedRow(1)
else
menu:setSelectedRow(0)
end
--- Methods
--@section methods
--- Activate this menu.
-- This selects the most recently selected menu item (or the first item if none have been previously selected), and enables this menu's @{selectPrevious|selectPrevious}, @{selectNext|selectNext}, and @{click|click} methods.
-- @usage
-- local menu = Noble.Menu.new(false)
-- menu:activate()
function menu:activate()
active = true
self:select(self.currentItemNumber)
end
--- Deactivate this menu.
-- This deselects all menu items, and disables this menu's @{selectPrevious|selectPrevious}, @{selectNext|selectNext}, and @{click|click} methods.
-- @usage
-- local menu = Noble.Menu.new(true)
-- menu:deactivate()
function menu:deactivate()
self:setSelectedRow(0)
active = false
end
--- Check to see if this menu is currently active.
-- @treturn bool
function menu:isActive()
return active
end
--- Selects the previous item in this menu. <strong>This menu must be active.</strong>
-- @bool[opt=false] __force Force this method to run, even if this menu is not active.
-- @bool[opt=true] __wrapSelection Selects the final menu item if the first menu item is currently selected.
-- @see activate
-- @usage
-- TitleScreen.inputHandler.upButtonDown = function()
-- menu:selectPrevious()
-- end
function menu:selectPrevious(__force, __wrapSelection)
if (self:isActive() or __force) then
local wrapSelection = Utilities.handleOptionalBoolean(__wrapSelection, true)
self:selectPreviousRow(wrapSelection, false, false)
local _, row, _ = self:getSelection()
self.currentItemNumber = row
self.currentItemName = self.itemNames[row]
end
end
--- Selects the next previous item in this menu. <strong>This menu must be active.</strong>
-- @bool[opt=false] __force Force this method to run, even if this menu is not active.
-- @bool[opt=true] __wrapSelection Selects the first menu item if the final menu item is currently selected.
-- @see activate
-- @usage
-- TitleScreen.inputHandler.downButtonDown = function()
-- menu:selectNext()
-- end
function menu:selectNext(__force, __wrapSelection)
if (self:isActive() or __force) then
local wrapSelection = Utilities.handleOptionalBoolean(__wrapSelection, true)
self:selectNextRow(wrapSelection, false, false)
local _, row, _ = self:getSelection()
self.currentItemNumber = row
self.currentItemName = self.itemNames[row]
end
end
--- Selects a specific item in this menu, either by it's index, or it's name. <strong>This menu must be active.</strong>
-- @tparam int|string __menuItem The menu item to select. You can enter the item's number or it's name/key.
-- @bool[opt=false] __force Force this method to run, even if this menu is not active.
-- @see activate
-- @usage
-- function resetMenu()
-- menu:select(1, true)
-- menu:deactivate()
-- end
-- @usage
-- function resetMenu()
-- menu:select("Play Game", true)
-- menu:deactivate()
-- end
function menu:select(__menuItem, __force)
if (self:isActive() or __force) then
if (type(__menuItem) == 'number') then
if (__menuItem < 1) then
error("BONK: _menuItem must be a number greater than 0 (or a string).")
end
self:setSelectedRow(__menuItem)
elseif (type(__menuItem) == 'string') then
self:setSelectedRow(self.itemPositions[__menuItem])
else
error("BONK: _menuItem must be a number or string, silly.")
end
local _, row, _ = self:getSelection()
self.currentItemNumber = row
self.currentItemName = self.itemNames[row]
end
end
--- Runs the function associated with the currently selected menu item. <strong>This menu must be active.</strong>
-- @bool[opt=false] __force Force this method to run, even if this menu is not active.
-- @see activate
-- @usage
-- TitleScreen.inputHandler.AButtonDown = function()
-- menu:click()
-- end
function menu:click(__force)
if ((self:isActive() or __force) and self.clickHandlers[self.currentItemName] ~= nil) then
self.clickHandlers[self.currentItemName]()
end
end
--- Gets the display name of a menu item.
--
-- If a menu item does not have a display name, then the `__nameOrKey` (or its localized string) will be returned instead. This method is used internally when @{draw|draw} is called.
--
-- If this menu's `localized` value is true, a returned `__nameOrKey` will always be localized, but a returned display name is only localized if the `__displayNameIsALocalizationKey` argument was set to `true` when the display name was added.
-- @string __itemName The menu item you want the display name of.
-- @treturn string
-- @see addItem
-- @see setItemDisplayName
function menu:getItemDisplayName(__itemName)
if (self.displayNames[__itemName] == nil) then
-- No display name.
if (self.localized) then
return Graphics.getLocalizedText(__itemName)
else
return __itemName
end
else
-- Has display name.
if (self.displayNamesAreLocalized[__itemName] == true) then
return Graphics.getLocalizedText(self.displayNames[__itemName])
else
return self.displayNames[__itemName]
end
end
end
--- When you add a menu item, you can give it a display name that's different from it's actual name. This method adds or changes the display name of a menu item.
-- @string __itemName The menu item name (or key if this menu uses localization keys).
-- @string __displayName The display name.
-- @bool[opt=false] __displayNameIsALocalizationKey Set to use to indicate that this display name is a localization key. This setting is separate from @{localized|localized}
-- @usage
-- function changeDifficultyLevel(__level)
-- menu:setItemDisplayName("Difficulty", "Difficulty: " .. __level)
-- end
function menu:setItemDisplayName(__itemName, __displayName, __displayNameIsALocalizationKey)
self.displayNames[__itemName] = __displayName
self.displayNamesAreLocalized[__itemName] = Utilities.handleOptionalBoolean(__displayNameIsALocalizationKey, false)
local displayName
if (__displayNameIsALocalizationKey == true) then
displayName = Graphics.getLocalizedText(__displayName)
else
displayName = __displayName
end
-- If we're "resetting" the display name by setting it to nil, then use __nameOrKey instead (checking for localization before calling updateWidths)
if (displayName == nil) then
if (self.localized) then
displayName = Graphics.getLocalizedText(__itemName)
else
displayName = __itemName
end
end
self:updateWidths(__itemName, displayName)
end
--- Drawing
--@section drawing
--- Draw's this menu to the screen. You may call this manually, but ideally, you will put it in in your scene's @{NobleScene:update|update} or @{NobleScene:drawBackground|drawBackground} method.
-- @usage
-- function YourScene:update()
-- YourScene.super.update(self)
-- menu:draw(50, 100)
-- end
function menu:draw(__x, __y)
local xAdjustment = 0
if (self.alignment == Noble.Text.ALIGN_CENTER) then
xAdjustment = self.width/2
elseif (self.alignment == Noble.Text.ALIGN_RIGHT) then
xAdjustment = self.width
end
self:drawInRect(__x - xAdjustment, __y, self.width, ((self.textHeight + self.padding + self.margin) * #self.itemNames) + (self.selectedOutlineThickness * 2) - self.margin)
end
--- This method is called for every <strong>non-selected</strong> item when @{draw|draw} is called. You shouldn't call this directly, but you may re-implement it if you wish.
-- @usage
-- -- This is the default implementation for this method.
-- function menu:drawItem(__x, __y, __itemIndex)
-- Graphics.setImageDrawMode(self.fillMode)
-- local xAdjustment = 0
-- if (self.alignment == Noble.Text.ALIGN_CENTER) then
-- xAdjustment = self.width/2 - self.horizontalPadding/2
-- elseif (self.alignment == Noble.Text.ALIGN_RIGHT) then
-- xAdjustment = self.width - self.horizontalPadding
-- end
-- Noble.Text.draw(self.itemNames[__itemIndex], __x + self.horizontalPadding/2 + xAdjustment, __y + self.padding/2, self.alignment, self.localized, self.font)
-- end
-- @see Noble.Text.draw
function menu:drawItem(__x, __y, __itemIndex)
Graphics.setImageDrawMode(self.fillMode)
local xAdjustment = self.selectedOutlineThickness
if (self.alignment == Noble.Text.ALIGN_CENTER) then
xAdjustment = self.width/2 - self.horizontalPadding/2
elseif (self.alignment == Noble.Text.ALIGN_RIGHT) then
xAdjustment = self.width - self.horizontalPadding - self.selectedOutlineThickness
end
Noble.Text.draw(
self:getItemDisplayName(self.itemNames[__itemIndex]),
__x + self.horizontalPadding/2 + xAdjustment, __y + self.padding/2 + self.selectedOutlineThickness + (self.margin * (__itemIndex -1)),
self.alignment, false, self.font
)
end
--- This method is called for every <strong>selected</strong> item when @{draw|draw} is called. You shouldn't call this directly, but you may re-implement it if you wish.
-- @usage
-- -- This is the default implementation for this method.
-- function menu:drawSelectedItem(__x, __y, __itemIndex)
-- local xAdjustmentText = 0
-- local xAdjustmentRect = 0
-- if (self.alignment == Noble.Text.ALIGN_CENTER) then
-- xAdjustmentText = self.width/2 - self.horizontalPadding/2
-- xAdjustmentRect = self.width/2 - self.itemWidths[self.itemNames[__itemIndex]]/2 - self.horizontalPadding/2
-- elseif (self.alignment == Noble.Text.ALIGN_RIGHT) then
-- xAdjustmentText = self.width - self.horizontalPadding
-- xAdjustmentRect = self.width - self.itemWidths[self.itemNames[__itemIndex]] - self.horizontalPadding
-- end
-- Graphics.setColor(self.color)
-- Graphics.fillRoundRect(__x + xAdjustmentRect, __y, self.itemWidths[self.itemNames[__itemIndex]]+self.horizontalPadding, self.textHeight+self.padding, self.selectedCornerRadius)
-- Graphics.setColor(self.otherColor)
-- Graphics.setLineWidth(self.selectedOutlineThickness)
-- Graphics.drawRoundRect(__x + xAdjustmentRect, __y, self.itemWidths[self.itemNames[__itemIndex]]+self.horizontalPadding, self.textHeight+self.padding, self.selectedCornerRadius)
-- Graphics.setImageDrawMode(self.otherFillMode)
-- Noble.Text.draw(self.itemNames[__itemIndex], __x + self.horizontalPadding/2 + xAdjustmentText, __y+self.padding/2, self.alignment, self.localized, self.font)
-- end
-- @see Noble.Text.draw
function menu:drawSelectedItem(__x, __y, __itemIndex)
local xAdjustmentText = self.selectedOutlineThickness
local xAdjustmentRect = self.selectedOutlineThickness
if (self.alignment == Noble.Text.ALIGN_CENTER) then
xAdjustmentText = self.width/2 - self.horizontalPadding/2
xAdjustmentRect = self.width/2 - self.itemWidths[self.itemNames[__itemIndex]]/2 - self.horizontalPadding/2
elseif (self.alignment == Noble.Text.ALIGN_RIGHT) then
xAdjustmentText = self.width - self.horizontalPadding - self.selectedOutlineThickness
xAdjustmentRect = self.width - self.itemWidths[self.itemNames[__itemIndex]] - self.horizontalPadding - self.selectedOutlineThickness
end
Graphics.setColor(self.color)
Graphics.fillRoundRect(__x + xAdjustmentRect, __y + self.selectedOutlineThickness + (self.margin * (__itemIndex -1)), self.itemWidths[self.itemNames[__itemIndex]]+self.horizontalPadding, self.textHeight+self.padding, self.selectedCornerRadius)
Graphics.setColor(self.otherColor)
Graphics.setLineWidth(self.selectedOutlineThickness)
Graphics.drawRoundRect(__x + xAdjustmentRect, __y + self.selectedOutlineThickness + (self.margin * (__itemIndex -1)), self.itemWidths[self.itemNames[__itemIndex]]+self.horizontalPadding, self.textHeight+self.padding, self.selectedCornerRadius)
Graphics.setImageDrawMode(self.otherFillMode)
Noble.Text.draw(
self:getItemDisplayName(self.itemNames[__itemIndex]),
__x + self.horizontalPadding/2 + xAdjustmentText, __y + self.padding/2 + self.selectedOutlineThickness + (self.margin * (__itemIndex -1)),
self.alignment, false, self.font
)
end
-- Don't call or modify this function.
function menu:drawCell(_, row, _, selected, x, y, width, height)
if selected then
self:drawSelectedItem(x, y, row)
else
self:drawItem(x, y, row)
end
end
return menu
end