Files
Basalt2/src/elements/ComboBox.lua
Robert Jelic 264b1b9425 Class rename
2025-09-14 12:19:36 +02:00

424 lines
14 KiB
Lua

local VisualElement = require("elements/VisualElement")
local Dropdown = require("src.elements.DropDown")
local tHex = require("libraries/colorHex")
---@configDescription A ComboBox that combines dropdown selection with editable text input
---@configDefault false
--- This is the ComboBox class. It extends the dropdown functionality with editable text input,
--- allowing users to either select from a list or type their own custom text.
--- @usage local ComboBox = main:addCombobox()
--- @usage ComboBox:setEditable(true)
--- @usage ComboBox:setItems({
--- @usage {text = "Option 1"},
--- @usage {text = "Option 2"},
--- @usage {text = "Option 3"},
--- @usage })
--- @usage ComboBox:setText("Custom input...")
---@class ComboBox : Dropdown
local ComboBox = setmetatable({}, Dropdown)
ComboBox.__index = ComboBox
---@property editable boolean true Whether the ComboBox allows text input
ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true})
---@property text string "" The current text content of the ComboBox
ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true})
---@property cursorPos number 1 The current cursor position in the text
ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"})
---@property viewOffset number 0 The horizontal scroll offset for viewing long text
ComboBox.defineProperty(ComboBox, "viewOffset", {default = 0, type = "number", canTriggerRender = true})
---@property placeholder string "..." Text to display when input is empty
ComboBox.defineProperty(ComboBox, "placeholder", {default = "...", type = "string"})
---@property placeholderColor color gray Color of the placeholder text
ComboBox.defineProperty(ComboBox, "placeholderColor", {default = colors.gray, type = "color"})
---@property focusedBackground color blue Background color when ComboBox is focused
ComboBox.defineProperty(ComboBox, "focusedBackground", {default = colors.blue, type = "color"})
---@property focusedForeground color white Foreground color when ComboBox is focused
ComboBox.defineProperty(ComboBox, "focusedForeground", {default = colors.white, type = "color"})
---@property autoComplete boolean false Whether to enable auto-complete filtering when typing
ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"})
---@property manuallyOpened boolean false Whether the dropdown was manually opened (not by auto-complete)
ComboBox.defineProperty(ComboBox, "manuallyOpened", {default = false, type = "boolean"})
--- Creates a new ComboBox instance
--- @shortDescription Creates a new ComboBox instance
--- @return ComboBox self The newly created ComboBox instance
function ComboBox.new()
local self = setmetatable({}, ComboBox):__init()
self.class = ComboBox
self.set("width", 16)
self.set("height", 1)
self.set("z", 8)
return self
end
--- @shortDescription Initializes the ComboBox instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @return ComboBox self The initialized instance
--- @protected
function ComboBox:init(props, basalt)
Dropdown.init(self, props, basalt)
self.set("type", "ComboBox")
self.set("cursorPos", 1)
self.set("viewOffset", 0)
return self
end
--- Sets the text content of the ComboBox
--- @shortDescription Sets the text content
--- @param text string The text to set
--- @return ComboBox self
function ComboBox:setText(text)
if text == nil then text = "" end
self.set("text", tostring(text))
self.set("cursorPos", #self.get("text") + 1)
self:updateViewport()
return self
end
--- Gets the current text content
--- @shortDescription Gets the text content
--- @return string text The current text
function ComboBox:getText()
return self.get("text")
end
--- Sets whether the ComboBox is editable
--- @shortDescription Sets editable state
--- @param editable boolean Whether the ComboBox should be editable
--- @return ComboBox self
function ComboBox:setEditable(editable)
self.set("editable", editable)
return self
end
--- Filters items based on current text for auto-complete
--- @shortDescription Filters items for auto-complete
--- @private
function ComboBox:getFilteredItems()
local allItems = self.get("items") or {}
local currentText = self.get("text"):lower()
if not self.get("autoComplete") or #currentText == 0 then
return allItems
end
local filteredItems = {}
for _, item in ipairs(allItems) do
local itemText = ""
if type(item) == "string" then
itemText = item:lower()
elseif type(item) == "table" and item.text then
itemText = item.text:lower()
end
if itemText:find(currentText, 1, true) then
table.insert(filteredItems, item)
end
end
return filteredItems
end
--- Updates the dropdown with filtered items
--- @shortDescription Updates dropdown with filtered items
--- @private
function ComboBox:updateFilteredDropdown()
if not self.get("autoComplete") then return end
local filteredItems = self:getFilteredItems()
local shouldOpen = #filteredItems > 0 and #self.get("text") > 0
if shouldOpen then
self.set("isOpen", true)
self.set("manuallyOpened", false)
local dropdownHeight = self.get("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #filteredItems)
self.set("height", 1 + actualHeight)
else
self.set("isOpen", false)
self.set("manuallyOpened", false)
self.set("height", 1)
end
self:updateRender()
end
--- @shortDescription Updates the viewport
--- @private
function ComboBox:updateViewport()
local text = self.get("text")
local cursorPos = self.get("cursorPos")
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local textWidth = width - #dropSymbol
if textWidth < 1 then textWidth = 1 end
local viewOffset = self.get("viewOffset")
if cursorPos - viewOffset > textWidth then
viewOffset = cursorPos - textWidth
elseif cursorPos - 1 < viewOffset then
viewOffset = math.max(0, cursorPos - 1)
end
self.set("viewOffset", viewOffset)
end
--- Handles character input when editable
--- @shortDescription Handles character input
--- @param char string The character that was typed
function ComboBox:char(char)
if not self.get("editable") then return end
if not self.get("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
local newText = text:sub(1, cursorPos - 1) .. char .. text:sub(cursorPos)
self.set("text", newText)
self.set("cursorPos", cursorPos + 1)
self:updateViewport()
if self.get("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
end
end
--- Handles key input when editable
--- @shortDescription Handles key input
--- @param key number The key code that was pressed
--- @param held boolean Whether the key is being held
function ComboBox:key(key, held)
if not self.get("editable") then return end
if not self.get("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
if key == keys.left then
self.set("cursorPos", math.max(1, cursorPos - 1))
self:updateViewport()
elseif key == keys.right then
self.set("cursorPos", math.min(#text + 1, cursorPos + 1))
self:updateViewport()
elseif key == keys.backspace then
if cursorPos > 1 then
local newText = text:sub(1, cursorPos - 2) .. text:sub(cursorPos)
self.set("text", newText)
self.set("cursorPos", cursorPos - 1)
self:updateViewport()
if self.get("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
end
end
elseif key == keys.delete then
if cursorPos <= #text then
local newText = text:sub(1, cursorPos - 1) .. text:sub(cursorPos + 1)
self.set("text", newText)
self:updateViewport()
if self.get("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
end
end
elseif key == keys.home then
self.set("cursorPos", 1)
self:updateViewport()
elseif key == keys["end"] then
self.set("cursorPos", #text + 1)
self:updateViewport()
elseif key == keys.enter then
self.set("isOpen", not self.get("isOpen"))
self:updateRender()
end
end
--- Handles mouse clicks
--- @shortDescription Handles mouse clicks
--- @param button number The mouse button (1 = left, 2 = right, 3 = middle)
--- @param x number The x coordinate of the click
--- @param y number The y coordinate of the click
--- @return boolean handled Whether the event was handled
--- @protected
function ComboBox:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
if relY == 1 then
if relX >= width - #dropSymbol + 1 and relX <= width then
local isCurrentlyOpen = self.get("isOpen")
self.set("isOpen", not isCurrentlyOpen)
if self.get("isOpen") then
local allItems = self.get("items") or {}
local dropdownHeight = self.get("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #allItems)
self.set("height", 1 + actualHeight)
self.set("manuallyOpened", true)
else
self.set("height", 1)
self.set("manuallyOpened", false)
end
self:updateRender()
return true
end
if relX <= width - #dropSymbol and self.get("editable") then
local text = self.get("text")
local viewOffset = self.get("viewOffset")
local maxPos = #text + 1
local targetPos = math.min(maxPos, viewOffset + relX)
self.set("cursorPos", targetPos)
self:updateRender()
return true
end
return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then
local itemIndex = (relY - 1) + self.get("offset")
local items = self.get("items")
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end
end
item.selected = true
if item.text then
self:setText(item.text)
end
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
return true
end
end
return false
end
--- Renders the ComboBox
--- @shortDescription Renders the ComboBox
function ComboBox:render()
VisualElement.render(self)
local text = self.get("text")
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local isFocused = self.get("focused")
local isOpen = self.get("isOpen")
local viewOffset = self.get("viewOffset")
local placeholder = self.get("placeholder")
local bg = isFocused and self.get("focusedBackground") or self.get("background")
local fg = isFocused and self.get("focusedForeground") or self.get("foreground")
local displayText = text
local textWidth = width - #dropSymbol
if #text == 0 and not isFocused and #placeholder > 0 then
displayText = placeholder
fg = self.get("placeholderColor")
end
if #displayText > 0 then
displayText = displayText:sub(viewOffset + 1, viewOffset + textWidth)
end
displayText = displayText .. string.rep(" ", textWidth - #displayText)
local fullText = displayText .. (isOpen and "\31" or "\17")
self:blit(1, 1, fullText,
string.rep(tHex[fg], width),
string.rep(tHex[bg], width))
if isFocused and self.get("editable") then
local cursorPos = self.get("cursorPos")
local cursorX = cursorPos - viewOffset
if cursorX >= 1 and cursorX <= textWidth then
self:setCursor(cursorX, 1, true, self.get("foreground"))
end
end
if isOpen then
local items
if self.get("autoComplete") and not self.get("manuallyOpened") then
items = self:getFilteredItems()
else
items = self.get("items")
end
local dropdownHeight = math.min(self.get("dropdownHeight"), #items)
if dropdownHeight > 0 then
local offset = self.get("offset")
for i = 1, dropdownHeight do
local itemIndex = i + offset
if items[itemIndex] then
local item = items[itemIndex]
local itemText = item.text or ""
local isSelected = item.selected or false
local itemBg = isSelected and self.get("selectedBackground") or self.get("background")
local itemFg = isSelected and self.get("selectedForeground") or self.get("foreground")
if #itemText > width then
itemText = itemText:sub(1, width)
end
itemText = itemText .. string.rep(" ", width - #itemText)
self:blit(1, i + 1, itemText,
string.rep(tHex[itemFg], width),
string.rep(tHex[itemBg], width))
end
end
end
end
end
--- Called when the ComboBox gains focus
--- @shortDescription Called when gaining focus
function ComboBox:focus()
Dropdown.focus(self)
-- Additional focus logic for input if needed
end
--- Called when the ComboBox loses focus
--- @shortDescription Called when losing focus
function ComboBox:blur()
Dropdown.blur(self)
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
end
return ComboBox