Files
Basalt2/src/elements/TextBox.lua
Robert Jelic 2ca7ad1e4c - Added comprehensive state management with conditional states, priority-based resolution, and property overrides
- Added responsive.lua with fluent builder API (:when()/:apply()/:otherwise()) for creating responsive layouts that react to parent size or custom conditions
- All elements now use getResolved() to check active states, enabling multiple responsive rules to coexist
2025-11-04 22:40:37 +01:00

1161 lines
43 KiB
Lua

---@diagnostic disable: duplicate-set-field
local VisualElement = require("elements/VisualElement")
local tHex = require("libraries/colorHex")
---@configDescription A multi-line text editor component with cursor support and text manipulation features
---@configDefault false
---A multi-line text editor component with cursor support and text manipulation features
---@class TextBox : VisualElement
local TextBox = setmetatable({}, VisualElement)
TextBox.__index = TextBox
---@property lines table {} Array of text lines
TextBox.defineProperty(TextBox, "lines", {default = {""}, type = "table", canTriggerRender = true})
---@property cursorX number 1 Cursor X position
TextBox.defineProperty(TextBox, "cursorX", {default = 1, type = "number"})
---@property cursorY number 1 Cursor Y position (line number)
TextBox.defineProperty(TextBox, "cursorY", {default = 1, type = "number"})
---@property scrollX number 0 Horizontal scroll offset
TextBox.defineProperty(TextBox, "scrollX", {default = 0, type = "number", canTriggerRender = true})
---@property scrollY number 0 Vertical scroll offset
TextBox.defineProperty(TextBox, "scrollY", {default = 0, type = "number", canTriggerRender = true})
---@property editable boolean true Whether text can be edited
TextBox.defineProperty(TextBox, "editable", {default = true, type = "boolean"})
---@property syntaxPatterns table {} Syntax highlighting patterns
TextBox.defineProperty(TextBox, "syntaxPatterns", {default = {}, type = "table"})
---@property cursorColor number nil Color of the cursor
TextBox.defineProperty(TextBox, "cursorColor", {default = nil, type = "color"})
---@property autoPairEnabled boolean true Whether automatic bracket/quote pairing is enabled
TextBox.defineProperty(TextBox, "autoPairEnabled", {default = true, type = "boolean"})
---@property autoPairCharacters table { ["("]=")", ["["]="]", ["{"]="}", ['"']='"', ['\'']='\'', ['`']='`'} Mapping of opening to closing characters for auto pairing
TextBox.defineProperty(TextBox, "autoPairCharacters", {default = { ["("]=")", ["["]="]", ["{"]="}", ['"']='"', ['\'']='\'', ['`']='`' }, type = "table"})
---@property autoPairSkipClosing boolean true Skip inserting a closing char if the same one is already at cursor
TextBox.defineProperty(TextBox, "autoPairSkipClosing", {default = true, type = "boolean"})
---@property autoPairOverType boolean true When pressing a closing char that matches the next char, move over it instead of inserting
TextBox.defineProperty(TextBox, "autoPairOverType", {default = true, type = "boolean"})
---@property autoPairNewlineIndent boolean true On Enter between matching braces, create blank line and keep closing aligned
TextBox.defineProperty(TextBox, "autoPairNewlineIndent", {default = true, type = "boolean"})
---@property autoCompleteEnabled boolean false Whether autocomplete suggestions are enabled
TextBox.defineProperty(TextBox, "autoCompleteEnabled", {default = false, type = "boolean"})
---@property autoCompleteItems table {} List of suggestions used when no provider is supplied
TextBox.defineProperty(TextBox, "autoCompleteItems", {default = {}, type = "table"})
---@property autoCompleteProvider function nil Optional suggestion provider returning a list for the current prefix
TextBox.defineProperty(TextBox, "autoCompleteProvider", {default = nil, type = "function", allowNil = true})
---@property autoCompleteMinChars number 1 Minimum characters required before showing suggestions
TextBox.defineProperty(TextBox, "autoCompleteMinChars", {default = 1, type = "number"})
---@property autoCompleteMaxItems number 6 Maximum number of visible suggestions
TextBox.defineProperty(TextBox, "autoCompleteMaxItems", {default = 6, type = "number"})
---@property autoCompleteCaseInsensitive boolean true Whether suggestions should match case-insensitively
TextBox.defineProperty(TextBox, "autoCompleteCaseInsensitive", {default = true, type = "boolean"})
---@property autoCompleteTokenPattern string "[%w_]+" Pattern used to extract the current token for suggestions
TextBox.defineProperty(TextBox, "autoCompleteTokenPattern", {default = "[%w_]+", type = "string"})
---@property autoCompleteOffsetX number 0 Horizontal offset applied to the popup frame relative to the TextBox
TextBox.defineProperty(TextBox, "autoCompleteOffsetX", {default = 0, type = "number"})
---@property autoCompleteOffsetY number 1 Vertical offset applied to the popup frame relative to the TextBox bottom edge
TextBox.defineProperty(TextBox, "autoCompleteOffsetY", {default = 1, type = "number"})
---@property autoCompleteZOffset number 1 Z-index offset applied to the popup frame
TextBox.defineProperty(TextBox, "autoCompleteZOffset", {default = 1, type = "number"})
---@property autoCompleteMaxWidth number 0 Maximum width of the autocomplete popup (0 uses the textbox width)
TextBox.defineProperty(TextBox, "autoCompleteMaxWidth", {default = 0, type = "number"})
---@property autoCompleteShowBorder boolean true Whether to render a character border around the popup
TextBox.defineProperty(TextBox, "autoCompleteShowBorder", {default = true, type = "boolean"})
---@property autoCompleteBorderColor color black Color of the popup border when enabled
TextBox.defineProperty(TextBox, "autoCompleteBorderColor", {default = colors.black, type = "color"})
---@property autoCompleteBackground color lightGray Background color of the suggestion popup
TextBox.defineProperty(TextBox, "autoCompleteBackground", {default = colors.lightGray, type = "color"})
---@property autoCompleteForeground color black Foreground color of the suggestion popup
TextBox.defineProperty(TextBox, "autoCompleteForeground", {default = colors.black, type = "color"})
---@property autoCompleteSelectedBackground color gray Background color for the selected suggestion
TextBox.defineProperty(TextBox, "autoCompleteSelectedBackground", {default = colors.gray, type = "color"})
---@property autoCompleteSelectedForeground color white Foreground color for the selected suggestion
TextBox.defineProperty(TextBox, "autoCompleteSelectedForeground", {default = colors.white, type = "color"})
---@property autoCompleteAcceptOnEnter boolean true Whether pressing Enter accepts the current suggestion
TextBox.defineProperty(TextBox, "autoCompleteAcceptOnEnter", {default = true, type = "boolean"})
---@property autoCompleteAcceptOnClick boolean true Whether clicking a suggestion accepts it immediately
TextBox.defineProperty(TextBox, "autoCompleteAcceptOnClick", {default = true, type = "boolean"})
---@property autoCompleteCloseOnEscape boolean true Whether pressing Escape closes the popup
TextBox.defineProperty(TextBox, "autoCompleteCloseOnEscape", {default = true, type = "boolean"})
TextBox.defineEvent(TextBox, "mouse_click")
TextBox.defineEvent(TextBox, "key")
TextBox.defineEvent(TextBox, "char")
TextBox.defineEvent(TextBox, "mouse_scroll")
TextBox.defineEvent(TextBox, "paste")
TextBox.defineEvent(TextBox, "auto_complete_open")
TextBox.defineEvent(TextBox, "auto_complete_close")
TextBox.defineEvent(TextBox, "auto_complete_accept")
local updateAutoCompleteBorder
local layoutAutoCompleteList
local function autoCompleteVisible(self)
local frame = self._autoCompleteFrame
return frame and not frame._destroyed and frame.get and frame.get("visible")
end
local function getBorderPadding(self)
return self.getResolved("autoCompleteShowBorder") and 1 or 0
end
local function updateAutoCompleteStyles(self)
local frame = self._autoCompleteFrame
local list = self._autoCompleteList
if not frame or frame._destroyed then return end
frame:setBackground(self.getResolved("autoCompleteBackground"))
frame:setForeground(self.getResolved("autoCompleteForeground"))
if list and not list._destroyed then
list:setBackground(self.getResolved("autoCompleteBackground"))
list:setForeground(self.getResolved("autoCompleteForeground"))
list:setSelectedBackground(self.getResolved("autoCompleteSelectedBackground"))
list:setSelectedForeground(self.getResolved("autoCompleteSelectedForeground"))
list:updateRender()
end
layoutAutoCompleteList(self)
updateAutoCompleteBorder(self)
frame:updateRender()
end
local function setAutoCompleteSelection(self, index, clampOnly)
local list = self._autoCompleteList
if not list or list._destroyed then return end
local items = list.get("items")
local count = #items
if count == 0 then return end
if index < 1 then index = 1 end
if index > count then index = count end
self._autoCompleteIndex = index
for i, item in ipairs(items) do
if type(item) == "table" then
item.selected = (i == index)
end
end
local height = list.get("height") or 0
local offset = list.get("offset") or 0
if not clampOnly and height > 0 then
if index > offset + height then
list:setOffset(math.max(0, index - height))
elseif index <= offset then
list:setOffset(math.max(0, index - 1))
end
end
list:updateRender()
end
local function hideAutoComplete(self, silent)
if autoCompleteVisible(self) then
self._autoCompleteFrame:setVisible(false)
if not silent then
self:fireEvent("auto_complete_close")
end
end
self._autoCompleteIndex = nil
self._autoCompleteSuggestions = nil
self._autoCompleteToken = nil
self._autoCompleteTokenStart = nil
self._autoCompletePopupWidth = nil
end
local function applyAutoCompleteSelection(self, item)
local suggestions = self._autoCompleteSuggestions or {}
local index = self._autoCompleteIndex or 1
local entry = item or suggestions[index]
if not entry then return end
local insertText = entry.insert or entry.text or ""
if insertText == "" then return end
local lines = self.getResolved("lines")
local cursorY = self.getResolved("cursorY")
local cursorX = self.getResolved("cursorX")
local line = lines[cursorY] or ""
local startIndex = self._autoCompleteTokenStart or cursorX
if startIndex < 1 then startIndex = 1 end
local before = line:sub(1, startIndex - 1)
local after = line:sub(cursorX)
lines[cursorY] = before .. insertText .. after
self.set("cursorX", startIndex + #insertText)
self:updateViewport()
self:updateRender()
hideAutoComplete(self, true)
self:fireEvent("auto_complete_accept", insertText, entry.source or entry)
end
local function ensureAutoCompleteUI(self)
if not self.getResolved("autoCompleteEnabled") then return nil end
local frame = self._autoCompleteFrame
if frame and not frame._destroyed then
return self._autoCompleteList
end
local base = self:getBaseFrame()
if not base or not base.addFrame then return nil end
frame = base:addFrame({
width = self.getResolved("width"),
height = 1,
x = 1,
y = 1,
visible = false,
background = self.getResolved("autoCompleteBackground"),
foreground = self.getResolved("autoCompleteForeground"),
ignoreOffset = true,
z = self.getResolved("z") + self.getResolved("autoCompleteZOffset"),
})
frame:setIgnoreOffset(true)
frame:setVisible(false)
local padding = getBorderPadding(self)
local list = frame:addList({
x = padding + 1,
y = padding + 1,
width = math.max(1, frame.get("width") - padding * 2),
height = math.max(1, frame.get("height") - padding * 2),
selectable = true,
multiSelection = false,
background = self.getResolved("autoCompleteBackground"),
foreground = self.getResolved("autoCompleteForeground"),
})
list:setSelectedBackground(self.getResolved("autoCompleteSelectedBackground"))
list:setSelectedForeground(self.getResolved("autoCompleteSelectedForeground"))
list:setOffset(0)
list:onSelect(function(_, index, selectedItem)
if not autoCompleteVisible(self) then return end
setAutoCompleteSelection(self, index)
if self.getResolved("autoCompleteAcceptOnClick") then
applyAutoCompleteSelection(self, selectedItem)
end
end)
self._autoCompleteFrame = frame
self._autoCompleteList = list
updateAutoCompleteStyles(self)
return list
end
layoutAutoCompleteList = function(self, contentWidth, visibleCount)
local frame = self._autoCompleteFrame
local list = self._autoCompleteList
if not frame or frame._destroyed or not list or list._destroyed then return end
local border = getBorderPadding(self)
local width = tonumber(contentWidth) or rawget(self, "_autoCompletePopupWidth") or list.get("width") or frame.get("width")
local height = tonumber(visibleCount) or (list.get and list.get("height")) or (#(rawget(self, "_autoCompleteSuggestions") or {}))
width = math.max(1, width or 1)
height = math.max(1, height or 1)
local frameWidth = frame.get and frame.get("width") or width
local frameHeight = frame.get and frame.get("height") or height
local maxWidth = math.max(1, frameWidth - border * 2)
local maxHeight = math.max(1, frameHeight - border * 2)
if width > maxWidth then width = maxWidth end
if height > maxHeight then height = maxHeight end
list:setPosition(border + 1, border + 1)
list:setWidth(math.max(1, width))
list:setHeight(math.max(1, height))
end
updateAutoCompleteBorder = function(self)
local frame = self._autoCompleteFrame
if not frame or frame._destroyed then return end
local canvas = frame.get and frame.get("canvas")
if not canvas then return end
canvas:setType("post")
if frame._autoCompleteBorderCommand then
canvas:removeCommand(frame._autoCompleteBorderCommand)
frame._autoCompleteBorderCommand = nil
end
if not self.getResolved("autoCompleteShowBorder") then
frame:updateRender()
return
end
local borderColor = self.getResolved("autoCompleteBorderColor") or colors.black
local commandIndex = canvas:addCommand(function(element)
local width = element.get("width") or 0
local height = element.get("height") or 0
if width < 1 or height < 1 then return end
local bgColor = element.get("background") or colors.black
local bgHex = tHex[bgColor] or tHex[colors.black]
local borderHex = tHex[borderColor] or tHex[colors.black]
element:textFg(1, 1, ("\131"):rep(width), borderColor)
element:multiBlit(1, height, width, 1, "\143", bgHex, borderHex)
element:multiBlit(1, 1, 1, height, "\149", borderHex, bgHex)
element:multiBlit(width, 1, 1, height, "\149", bgHex, borderHex)
element:blit(1, 1, "\151", borderHex, bgHex)
element:blit(width, 1, "\148", bgHex, borderHex)
element:blit(1, height, "\138", bgHex, borderHex)
element:blit(width, height, "\133", bgHex, borderHex)
end)
frame._autoCompleteBorderCommand = commandIndex
frame:updateRender()
end
local function getTokenInfo(self)
local lines = self.getResolved("lines")
local cursorY = self.getResolved("cursorY")
local cursorX = self.getResolved("cursorX")
local line = lines[cursorY] or ""
local uptoCursor = line:sub(1, math.max(cursorX - 1, 0))
local pattern = self.getResolved("autoCompleteTokenPattern") or "[%w_]+"
local token = ""
if pattern ~= "" then
token = uptoCursor:match("(" .. pattern .. ")$") or ""
end
local startIndex = cursorX - #token
if startIndex < 1 then startIndex = 1 end
return token, startIndex
end
local function normalizeSuggestion(entry)
if type(entry) == "string" then
return {text = entry, insert = entry, source = entry}
elseif type(entry) == "table" then
local text = entry.text or entry.label or entry.value or entry.insert or entry[1]
if not text then return nil end
local item = {
text = text,
insert = entry.insert or entry.value or text,
source = entry,
}
if entry.foreground then item.foreground = entry.foreground end
if entry.background then item.background = entry.background end
if entry.selectedForeground then item.selectedForeground = entry.selectedForeground end
if entry.selectedBackground then item.selectedBackground = entry.selectedBackground end
if entry.icon then item.icon = entry.icon end
if entry.info then item.info = entry.info end
return item
end
end
local function iterateSuggestions(source, handler)
if type(source) ~= "table" then return end
local length = #source
if length > 0 then
for index = 1, length do
handler(source[index])
end
else
for _, value in pairs(source) do
handler(value)
end
end
end
local function gatherSuggestions(self, token)
local provider = self.getResolved("autoCompleteProvider")
local source = {}
if provider then
local ok, result = pcall(provider, self, token)
if ok and type(result) == "table" then
source = result
end
else
source = self.getResolved("autoCompleteItems") or {}
end
local suggestions = {}
local caseInsensitive = self.getResolved("autoCompleteCaseInsensitive")
local target = caseInsensitive and token:lower() or token
iterateSuggestions(source, function(entry)
local normalized = normalizeSuggestion(entry)
if normalized and normalized.text then
local compare = caseInsensitive and normalized.text:lower() or normalized.text
if target == "" or compare:find(target, 1, true) == 1 then
table.insert(suggestions, normalized)
end
end
end)
local maxItems = self.getResolved("autoCompleteMaxItems")
if #suggestions > maxItems then
while #suggestions > maxItems do
table.remove(suggestions)
end
end
return suggestions
end
local function measureSuggestionWidth(self, suggestions)
local maxLen = 0
for _, entry in ipairs(suggestions) do
local text = entry
if type(entry) == "table" then
text = entry.text or entry.label or entry.value or entry.insert or entry[1]
end
if text ~= nil then
local len = #tostring(text)
if len > maxLen then
maxLen = len
end
end
end
local limit = self.getResolved("autoCompleteMaxWidth")
local maxWidth = self.getResolved("width")
if limit and limit > 0 then
maxWidth = math.min(maxWidth, limit)
end
local border = getBorderPadding(self)
local base = self:getBaseFrame()
if base and base.get then
local baseWidth = base.get("width")
if baseWidth and baseWidth > 0 then
local available = baseWidth - border * 2
if available < 1 then available = 1 end
maxWidth = math.min(maxWidth, available)
end
end
maxLen = math.min(maxLen, maxWidth)
return math.max(1, maxLen)
end
local function placeAutoCompleteFrame(self, visibleCount, width)
local frame = self._autoCompleteFrame
local list = self._autoCompleteList
if not frame or frame._destroyed then return end
local border = getBorderPadding(self)
local contentWidth = math.max(1, width or self.getResolved("width"))
local contentHeight = math.max(1, visibleCount or 1)
local base = self:getBaseFrame()
if not base then return end
local baseWidth = base.get and base.get("width")
local baseHeight = base.get and base.get("height")
if baseWidth and baseWidth > 0 then
local maxContentWidth = baseWidth - border * 2
if maxContentWidth < 1 then maxContentWidth = 1 end
if contentWidth > maxContentWidth then
contentWidth = maxContentWidth
end
end
if baseHeight and baseHeight > 0 then
local maxContentHeight = baseHeight - border * 2
if maxContentHeight < 1 then maxContentHeight = 1 end
if contentHeight > maxContentHeight then
contentHeight = maxContentHeight
end
end
local frameWidth = contentWidth + border * 2
local frameHeight = contentHeight + border * 2
local originX, originY = self:calculatePosition()
local scrollX = self.getResolved("scrollX") or 0
local scrollY = self.getResolved("scrollY") or 0
local tokenStart = (self._autoCompleteTokenStart or self.getResolved("cursorX"))
local column = tokenStart - scrollX
column = math.max(1, math.min(self.getResolved("width"), column))
local cursorRow = self.getResolved("cursorY") - scrollY
cursorRow = math.max(1, math.min(self.getResolved("height"), cursorRow))
local offsetX = self.getResolved("autoCompleteOffsetX")
local offsetY = self.getResolved("autoCompleteOffsetY")
local baseX = originX + column - 1 + offsetX
local x = baseX - border
if border > 0 then
x = x + 1
end
local listTopBelow = originY + cursorRow + offsetY
local listBottomAbove = originY + cursorRow - offsetY - 1
local belowY = listTopBelow - border
local aboveY = listBottomAbove - contentHeight + 1 - border
local y = belowY
if baseWidth and baseWidth > 0 then
if frameWidth > baseWidth then
frameWidth = baseWidth
contentWidth = math.max(1, frameWidth - border * 2)
end
if x + frameWidth - 1 > baseWidth then
x = math.max(1, baseWidth - frameWidth + 1)
end
if x < 1 then
x = 1
end
else
if x < 1 then x = 1 end
end
if baseHeight and baseHeight > 0 then
if y + frameHeight - 1 > baseHeight then
-- Place above
y = aboveY
if border > 0 then
-- Shift further up so lower border does not overlap the text line
y = y - border
end
if y < 1 then
y = math.max(1, baseHeight - frameHeight + 1)
end
end
if y < 1 then
y = 1
end
else
if y < 1 then y = 1 end
if y == aboveY and border > 0 then
y = math.max(1, y - border)
end
end
frame:setPosition(x, y)
frame:setWidth(frameWidth)
frame:setHeight(frameHeight)
frame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
layoutAutoCompleteList(self, contentWidth, contentHeight)
if list and not list._destroyed then
list:updateRender()
end
frame:updateRender()
end
local function refreshAutoComplete(self)
if not self.getResolved("autoCompleteEnabled") then
hideAutoComplete(self, true)
return
end
if not self:hasState("focused") then
hideAutoComplete(self, true)
return
end
local token, startIndex = getTokenInfo(self)
self._autoCompleteToken = token
self._autoCompleteTokenStart = startIndex
if #token < self.getResolved("autoCompleteMinChars") then
hideAutoComplete(self)
return
end
local suggestions = gatherSuggestions(self, token)
if #suggestions == 0 then
hideAutoComplete(self)
return
end
local list = ensureAutoCompleteUI(self)
if not list then return end
list:setOffset(0)
list:setItems(suggestions)
self._autoCompleteSuggestions = suggestions
setAutoCompleteSelection(self, 1, true)
local popupWidth = measureSuggestionWidth(self, suggestions)
self._autoCompletePopupWidth = popupWidth
placeAutoCompleteFrame(self, #suggestions, popupWidth)
updateAutoCompleteStyles(self)
self._autoCompleteFrame:setVisible(true)
self._autoCompleteList:updateRender()
self._autoCompleteFrame:updateRender()
self:fireEvent("auto_complete_open", token, suggestions)
end
local function handleAutoCompleteKey(self, key)
if not autoCompleteVisible(self) then return false end
if key == keys.tab or (key == keys.enter and self.getResolved("autoCompleteAcceptOnEnter")) then
applyAutoCompleteSelection(self)
return true
elseif key == keys.up then
setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) - 1)
return true
elseif key == keys.down then
setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) + 1)
return true
elseif key == keys.pageUp then
local height = (self._autoCompleteList and self._autoCompleteList.get("height")) or 1
setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) - height)
return true
elseif key == keys.pageDown then
local height = (self._autoCompleteList and self._autoCompleteList.get("height")) or 1
setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) + height)
return true
elseif key == keys.escape and self.getResolved("autoCompleteCloseOnEscape") then
hideAutoComplete(self)
return true
end
return false
end
local function handleAutoCompleteScroll(self, direction)
if not autoCompleteVisible(self) then return false end
local list = self._autoCompleteList
if not list or list._destroyed then return false end
local items = list.get("items")
local height = list.get("height") or 1
local offset = list.get("offset") or 0
local count = #items
if count == 0 then return false end
local maxOffset = math.max(0, count - height)
local newOffset = math.max(0, math.min(maxOffset, offset + direction))
if newOffset ~= offset then
list:setOffset(newOffset)
end
local target = (self._autoCompleteIndex or 1) + direction
if target >= 1 and target <= count then
setAutoCompleteSelection(self, target)
else
list:updateRender()
end
return true
end
--- Creates a new TextBox instance
--- @shortDescription Creates a new TextBox instance
--- @return TextBox self The newly created TextBox instance
--- @private
function TextBox.new()
local self = setmetatable({}, TextBox):__init()
self.class = TextBox
self.set("width", 20)
self.set("height", 10)
return self
end
--- @shortDescription Initializes the TextBox instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @return TextBox self The initialized instance
--- @protected
function TextBox:init(props, basalt)
VisualElement.init(self, props, basalt)
self.set("type", "TextBox")
local function refreshIfEnabled()
if self.getResolved("autoCompleteEnabled") and self:hasState("focused") then
refreshAutoComplete(self)
end
end
local function restyle()
updateAutoCompleteStyles(self)
end
local function reposition()
if autoCompleteVisible(self) then
local suggestions = rawget(self, "_autoCompleteSuggestions") or {}
placeAutoCompleteFrame(self, math.max(#suggestions, 1), rawget(self, "_autoCompletePopupWidth") or self.getResolved("width"))
end
end
self:observe("autoCompleteEnabled", function(_, value)
if not value then
hideAutoComplete(self, true)
elseif self:hasState("focused") then
refreshAutoComplete(self)
end
end)
--[[
self:observe("focused", function(_, focused)
if focused then
refreshIfEnabled()
else
hideAutoComplete(self, true)
end
end)]] -- needs a REWORK
self:observe("foreground", restyle)
self:observe("background", restyle)
self:observe("autoCompleteBackground", restyle)
self:observe("autoCompleteForeground", restyle)
self:observe("autoCompleteSelectedBackground", restyle)
self:observe("autoCompleteSelectedForeground", restyle)
self:observe("autoCompleteBorderColor", restyle)
self:observe("autoCompleteZOffset", function()
if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then
self._autoCompleteFrame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
end
end)
self:observe("z", function()
if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then
self._autoCompleteFrame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
end
end)
self:observe("autoCompleteShowBorder", function()
restyle()
reposition()
end)
for _, prop in ipairs({
"autoCompleteItems",
"autoCompleteProvider",
"autoCompleteMinChars",
"autoCompleteMaxItems",
"autoCompleteCaseInsensitive",
"autoCompleteTokenPattern",
"autoCompleteOffsetX",
"autoCompleteOffsetY",
}) do
self:observe(prop, refreshIfEnabled)
end
self:observe("x", reposition)
self:observe("y", reposition)
self:observe("width", function()
reposition()
refreshIfEnabled()
end)
self:observe("height", reposition)
self:observe("cursorX", reposition)
self:observe("cursorY", reposition)
self:observe("scrollX", reposition)
self:observe("scrollY", reposition)
self:observe("autoCompleteOffsetX", reposition)
self:observe("autoCompleteOffsetY", reposition)
self:observe("autoCompleteMaxWidth", function()
if autoCompleteVisible(self) then
local suggestions = rawget(self, "_autoCompleteSuggestions") or {}
if #suggestions > 0 then
local popupWidth = measureSuggestionWidth(self, suggestions)
self._autoCompletePopupWidth = popupWidth
placeAutoCompleteFrame(self, math.max(#suggestions, 1), popupWidth)
end
end
end)
return self
end
--- Adds a new syntax highlighting pattern
--- @shortDescription Adds a new syntax highlighting pattern
--- @param pattern string The regex pattern to match
--- @param color number The color to apply
--- @return TextBox self The TextBox instance
function TextBox:addSyntaxPattern(pattern, color)
table.insert(self.getResolved("syntaxPatterns"), {pattern = pattern, color = color})
return self
end
--- Removes a syntax pattern by index (1-based)
--- @param index number The index of the pattern to remove
--- @return TextBox self
function TextBox:removeSyntaxPattern(index)
local patterns = self.getResolved("syntaxPatterns") or {}
if type(index) ~= "number" then return self end
if index >= 1 and index <= #patterns then
table.remove(patterns, index)
self.set("syntaxPatterns", patterns)
self:updateRender()
end
return self
end
--- Clears all syntax highlighting patterns
--- @return TextBox self
function TextBox:clearSyntaxPatterns()
self.set("syntaxPatterns", {})
self:updateRender()
return self
end
local function insertChar(self, char)
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY]
lines[cursorY] = currentLine:sub(1, cursorX-1) .. char .. currentLine:sub(cursorX)
self.set("cursorX", cursorX + 1)
self:updateViewport()
self:updateRender()
end
local function insertText(self, text)
for i = 1, #text do
insertChar(self, text:sub(i,i))
end
end
local function newLine(self)
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY]
local restOfLine = currentLine:sub(cursorX)
lines[cursorY] = currentLine:sub(1, cursorX-1)
table.insert(lines, cursorY + 1, restOfLine)
self.set("cursorX", 1)
self.set("cursorY", cursorY + 1)
self:updateViewport()
self:updateRender()
end
local function backspace(self)
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY]
if cursorX > 1 then
lines[cursorY] = currentLine:sub(1, cursorX-2) .. currentLine:sub(cursorX)
self.set("cursorX", cursorX - 1)
elseif cursorY > 1 then
local previousLine = lines[cursorY-1]
self.set("cursorX", #previousLine + 1)
self.set("cursorY", cursorY - 1)
lines[cursorY-1] = previousLine .. currentLine
table.remove(lines, cursorY)
end
self:updateViewport()
self:updateRender()
end
--- Updates the viewport to keep the cursor in view
--- @shortDescription Updates the viewport to keep the cursor in view
--- @return TextBox self The TextBox instance
function TextBox:updateViewport()
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local scrollX = self.getResolved("scrollX")
local scrollY = self.getResolved("scrollY")
local width = self.getResolved("width")
local height = self.getResolved("height")
-- Horizontal scrolling
if cursorX - scrollX > width then
self.set("scrollX", cursorX - width)
elseif cursorX - scrollX < 1 then
self.set("scrollX", cursorX - 1)
end
-- Vertical scrolling
if cursorY - scrollY > height then
self.set("scrollY", cursorY - height)
elseif cursorY - scrollY < 1 then
self.set("scrollY", cursorY - 1)
end
return self
end
--- @shortDescription Handles character input
--- @param char string The character that was typed
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:char(char)
if not self.getResolved("editable") or not self:hasState("focused") then return false end
-- Auto-pair logic only triggers for single characters
local autoPair = self.getResolved("autoPairEnabled")
if autoPair and #char == 1 then
local map = self.getResolved("autoPairCharacters") or {}
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local line = lines[cursorY] or ""
local afterChar = line:sub(cursorX, cursorX)
-- If typed char is an opening pair and we should skip duplicating closing when already there
local closing = map[char]
if closing then
-- If skip closing and same closing already directly after, just insert opening?
insertChar(self, char)
if self.getResolved("autoPairSkipClosing") then
if afterChar ~= closing then
insertChar(self, closing)
-- Move cursor back inside pair
self.set("cursorX", self.getResolved("cursorX") - 1)
end
else
insertChar(self, closing)
self.set("cursorX", self.getResolved("cursorX") - 1)
end
refreshAutoComplete(self)
return true
end
-- If typed char is a closing we might want to overtype
if self.getResolved("autoPairOverType") then
for open, close in pairs(map) do
if char == close and afterChar == close then
-- move over instead of inserting
self.set("cursorX", cursorX + 1)
refreshAutoComplete(self)
return true
end
end
end
end
insertChar(self, char)
refreshAutoComplete(self)
return true
end
--- @shortDescription Handles key events
--- @param key number The key that was pressed
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:key(key)
if not self.getResolved("editable") or not self:hasState("focused") then return false end
if handleAutoCompleteKey(self, key) then
return true
end
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
if key == keys.enter then
-- Smart newline between matching braces/brackets if enabled
if self.getResolved("autoPairEnabled") and self.getResolved("autoPairNewlineIndent") then
local lines = self.getResolved("lines")
local cursorX = self.getResolved("cursorX")
local cursorY = self.getResolved("cursorY")
local line = lines[cursorY] or ""
local before = line:sub(1, cursorX - 1)
local after = line:sub(cursorX)
local pairMap = self.getResolved("autoPairCharacters") or {}
local inverse = {}
for o,c in pairs(pairMap) do inverse[c]=o end
local prevChar = before:sub(-1)
local nextChar = after:sub(1,1)
if prevChar ~= "" and nextChar ~= "" and pairMap[prevChar] == nextChar then
-- Split line into two with an empty line between, caret positioned on inner line
lines[cursorY] = before
table.insert(lines, cursorY + 1, "")
table.insert(lines, cursorY + 2, after)
self.set("cursorY", cursorY + 1)
self.set("cursorX", 1)
self:updateViewport()
self:updateRender()
refreshAutoComplete(self)
return true
end
end
newLine(self)
elseif key == keys.backspace then
backspace(self)
elseif key == keys.left then
if cursorX > 1 then
self.set("cursorX", cursorX - 1)
elseif cursorY > 1 then
self.set("cursorY", cursorY - 1)
self.set("cursorX", #lines[cursorY-1] + 1)
end
elseif key == keys.right then
if cursorX <= #lines[cursorY] then
self.set("cursorX", cursorX + 1)
elseif cursorY < #lines then
self.set("cursorY", cursorY + 1)
self.set("cursorX", 1)
end
elseif key == keys.up and cursorY > 1 then
self.set("cursorY", cursorY - 1)
self.set("cursorX", math.min(cursorX, #lines[cursorY-1] + 1))
elseif key == keys.down and cursorY < #lines then
self.set("cursorY", cursorY + 1)
self.set("cursorX", math.min(cursorX, #lines[cursorY+1] + 1))
end
self:updateRender()
self:updateViewport()
refreshAutoComplete(self)
return true
end
--- @shortDescription Handles mouse scroll events
--- @param direction number The scroll direction
--- @param x number The x position of the scroll
--- @param y number The y position of the scroll
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:mouse_scroll(direction, x, y)
if handleAutoCompleteScroll(self, direction) then
return true
end
if self:isInBounds(x, y) then
local scrollY = self.getResolved("scrollY")
local height = self.getResolved("height")
local lines = self.getResolved("lines")
local maxScroll = math.max(0, #lines - height + 2)
local newScroll = math.max(0, math.min(maxScroll, scrollY + direction))
self.set("scrollY", newScroll)
self:updateRender()
return true
end
return false
end
--- @shortDescription Handles mouse click events
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected
function TextBox:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local scrollX = self.getResolved("scrollX")
local scrollY = self.getResolved("scrollY")
local targetY = (relY or 0) + (scrollY or 0)
local lines = self.getResolved("lines") or {}
-- clamp and validate before indexing to avoid nil errors
if targetY < 1 then targetY = 1 end
if targetY <= #lines and lines[targetY] ~= nil then
self.set("cursorY", targetY)
local lineLen = #tostring(lines[targetY])
self.set("cursorX", math.min((relX or 1) + (scrollX or 0), lineLen + 1))
end
self:updateRender()
refreshAutoComplete(self)
return true
end
if autoCompleteVisible(self) then
local frame = self._autoCompleteFrame
if not (frame and frame:isInBounds(x, y)) and not self:isInBounds(x, y) then
hideAutoComplete(self)
end
end
return false
end
--- @shortDescription Handles paste events
--- @protected
function TextBox:paste(text)
if not self.getResolved("editable") or not self:hasState("focused") then return false end
for char in text:gmatch(".") do
if char == "\n" then
newLine(self)
else
insertChar(self, char)
end
end
refreshAutoComplete(self)
return true
end
--- Sets the text of the TextBox
--- @shortDescription Sets the text of the TextBox
--- @param text string The text to set
--- @return TextBox self The TextBox instance
function TextBox:setText(text)
local lines = {}
if text == "" then
lines = {""}
else
for line in (text.."\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
end
self.set("lines", lines)
hideAutoComplete(self, true)
return self
end
--- Gets the text of the TextBox
--- @shortDescription Gets the text of the TextBox
--- @return string text The text of the TextBox
function TextBox:getText()
return table.concat(self.getResolved("lines"), "\n")
end
local function applySyntaxHighlighting(self, line)
local text = line
local colors = string.rep(tHex[self.getResolved("foreground")], #text)
local patterns = self.getResolved("syntaxPatterns")
for _, syntax in ipairs(patterns) do
local start = 1
while true do
local s, e = text:find(syntax.pattern, start)
if not s then break end
local matchLen = e - s + 1
if matchLen <= 0 then
-- avoid infinite loops for zero-length matches: color one char and advance
colors = colors:sub(1, s-1) .. string.rep(tHex[syntax.color], 1) .. colors:sub(s+1)
start = s + 1
else
colors = colors:sub(1, s-1) .. string.rep(tHex[syntax.color], matchLen) .. colors:sub(e+1)
start = e + 1
end
end
end
return text, colors
end
--- @shortDescription Renders the TextBox with syntax highlighting
--- @protected
function TextBox:render()
VisualElement.render(self)
local lines = self.getResolved("lines")
local scrollX = self.getResolved("scrollX")
local scrollY = self.getResolved("scrollY")
local width = self.getResolved("width")
local height = self.getResolved("height")
local foreground = self.getResolved("foreground")
local background = self.getResolved("background")
local fg = tHex[foreground]
local bg = tHex[background]
for y = 1, height do
local lineNum = y + scrollY
local line = lines[lineNum] or ""
local fullText, fullColors = applySyntaxHighlighting(self, line)
local text = fullText:sub(scrollX + 1, scrollX + width)
local colors = fullColors:sub(scrollX + 1, scrollX + width)
local padLen = width - #text
if padLen > 0 then
text = text .. string.rep(" ", padLen)
colors = colors .. string.rep(tHex[foreground], padLen)
end
self:blit(1, y, text, colors, string.rep(bg, #text))
end
if self:hasState("focused") then
local relativeX = self.getResolved("cursorX") - scrollX
local relativeY = self.getResolved("cursorY") - scrollY
if relativeX >= 1 and relativeX <= width and relativeY >= 1 and relativeY <= height then
self:setCursor(relativeX, relativeY, true, self.getResolved("cursorColor") or foreground)
end
end
end
function TextBox:destroy()
if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then
self._autoCompleteFrame:destroy()
end
self._autoCompleteFrame = nil
self._autoCompleteList = nil
self._autoCompletePopupWidth = nil
VisualElement.destroy(self)
end
return TextBox