Files
FlexLove/modules/TextEditor.lua
2025-12-06 11:59:00 -05:00

1776 lines
51 KiB
Lua

local utf8 = utf8 or require("utf8")
---@class TextEditor
---@field editable boolean
---@field multiline boolean
---@field passwordMode boolean
---@field textWrap boolean|"word"|"char"
---@field maxLines number?
---@field maxLength number?
---@field placeholder string?
---@field inputType "text"|"number"|"email"|"url"
---@field textOverflow "clip"|"ellipsis"|"scroll"
---@field scrollable boolean
---@field autoGrow boolean
---@field selectOnFocus boolean
---@field sanitize boolean
---@field allowNewlines boolean
---@field allowTabs boolean
---@field customSanitizer function?
---@field cursorColor Color?
---@field selectionColor Color?
---@field cursorBlinkRate number
---@field _textBuffer string
---@field _lines table?
---@field _wrappedLines table?
---@field _textDirty boolean
---@field _cursorPosition number
---@field _cursorLine number
---@field _cursorColumn number
---@field _cursorBlinkTimer number
---@field _cursorVisible boolean
---@field _cursorBlinkPaused boolean
---@field _cursorBlinkPauseTimer number
---@field _selectionStart number?
---@field _selectionEnd number?
---@field _selectionAnchor number?
---@field _focused boolean
---@field _textScrollX number
---@field onFocus fun(element:Element)?
---@field onBlur fun(element:Element)?
---@field onTextInput fun(element:Element, text:string)?
---@field onTextChange fun(element:Element, text:string)?
---@field onEnter fun(element:Element)?
---@field onSanitize fun(element:Element, original:string, sanitized:string)?
---@field _Context table
---@field _StateManager table
---@field _Color table
---@field _FONT_CACHE table
---@field _getModifiers function
---@field _utils table
---@field _textDragOccurred boolean?
local TextEditor = {}
TextEditor.__index = TextEditor
---@class TextEditorConfig
---@field editable boolean -- Whether text is editable
---@field multiline boolean -- Whether multi-line is supported
---@field passwordMode boolean -- Whether to mask text
---@field textWrap boolean|"word"|"char" -- Text wrapping mode
---@field maxLines number? -- Maximum number of lines
---@field maxLength number? -- Maximum text length in characters
---@field placeholder string? -- Placeholder text when empty
---@field inputType "text"|"number"|"email"|"url" -- Input validation type
---@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior
---@field scrollable boolean -- Whether text is scrollable
---@field autoGrow boolean -- Whether element auto-grows with text
---@field selectOnFocus boolean -- Whether to select all text on focus
---@field sanitize boolean? -- Whether to sanitize text input (default: true)
---@field allowNewlines boolean? -- Whether to allow newline characters (default: true in multiline)
---@field allowTabs boolean? -- Whether to allow tab characters (default: true)
---@field customSanitizer function? -- Custom sanitization function
---@field cursorColor Color? -- Cursor color
---@field selectionColor Color? -- Selection background color
---@field cursorBlinkRate number -- Cursor blink rate in seconds
---Create a new TextEditor instance
---@param config TextEditorConfig
---@param deps table Dependencies {Context, StateManager, Color, utils}
---@return table TextEditor instance
function TextEditor.new(config, deps)
local self = setmetatable({}, TextEditor)
-- Store dependencies
self._Context = deps.Context
self._StateManager = deps.StateManager
self._Color = deps.Color
self._FONT_CACHE = deps.utils.FONT_CACHE
self._getModifiers = deps.utils.getModifiers
self._utils = deps.utils
-- Store configuration
self.editable = config.editable or false
self.multiline = config.multiline or false
self.passwordMode = config.passwordMode or false
self.textWrap = config.textWrap
self.maxLines = config.maxLines
self.maxLength = config.maxLength
self.placeholder = config.placeholder
self.inputType = config.inputType or "text"
self.textOverflow = config.textOverflow or "clip"
self.scrollable = config.scrollable
self.autoGrow = config.autoGrow
self.selectOnFocus = config.selectOnFocus or false
self.cursorColor = config.cursorColor
self.selectionColor = config.selectionColor
self.cursorBlinkRate = config.cursorBlinkRate or 0.5
-- Sanitization configuration
self.sanitize = config.sanitize ~= false -- Default to true
-- If allowNewlines is explicitly set, use that value; otherwise follow multiline setting
if config.allowNewlines ~= nil then
self.allowNewlines = config.allowNewlines
else
self.allowNewlines = self.multiline
end
self.allowTabs = config.allowTabs ~= false -- Default to true
self.customSanitizer = config.customSanitizer
-- Initialize text buffer state (with sanitization)
local initialText = config.text or ""
self._textBuffer = self:_sanitizeText(initialText)
self._lines = nil
self._wrappedLines = nil
self._textDirty = true
-- Initialize cursor state
self._cursorPosition = 0
self._cursorLine = 1
self._cursorColumn = 0
self._cursorBlinkTimer = 0
self._cursorVisible = true
self._cursorBlinkPaused = false
self._cursorBlinkPauseTimer = 0
-- Initialize selection state
self._selectionStart = nil
self._selectionEnd = nil
self._selectionAnchor = nil
-- Initialize focus state
self._focused = false
-- Initialize scroll state
self._textScrollX = 0
-- Store callbacks
self.onFocus = config.onFocus
self.onBlur = config.onBlur
self.onTextInput = config.onTextInput
self.onTextChange = config.onTextChange
self.onEnter = config.onEnter
self.onSanitize = config.onSanitize
return self
end
---Internal: Sanitize text input
---@param text string -- Text to sanitize
---@return string -- Sanitized text
function TextEditor:_sanitizeText(text)
if not self.sanitize then
return text
end
-- Use custom sanitizer if provided
if self.customSanitizer then
return self.customSanitizer(text) or text
end
local options = {
maxLength = self.maxLength,
allowNewlines = self.allowNewlines,
allowTabs = self.allowTabs,
trimWhitespace = false, -- Preserve whitespace in text editors
}
local sanitized = self._utils.sanitizeText(text, options)
return sanitized
end
---Restore state from StateManager (for immediate mode)
---@param element table The parent Element instance
function TextEditor:restoreState(element)
-- Restore state from StateManager if in immediate mode
if element._stateId and self._Context._immediateMode then
local state = self._StateManager.getState(element._stateId)
if state then
if state._focused then
self._focused = true
self._Context._focusedElement = element
end
if state._textBuffer and state._textBuffer ~= "" then
self._textBuffer = state._textBuffer
end
if state._cursorPosition then
self._cursorPosition = state._cursorPosition
end
if state._selectionStart then
self._selectionStart = state._selectionStart
end
if state._selectionEnd then
self._selectionEnd = state._selectionEnd
end
if state._cursorBlinkTimer then
self._cursorBlinkTimer = state._cursorBlinkTimer
end
if state._cursorVisible ~= nil then
self._cursorVisible = state._cursorVisible
end
if state._cursorBlinkPaused ~= nil then
self._cursorBlinkPaused = state._cursorBlinkPaused
end
if state._cursorBlinkPauseTimer then
self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer
end
end
end
end
-- ====================
-- Text Buffer Management
-- ====================
---Get current text buffer
---@return string
function TextEditor:getText()
return self._textBuffer or ""
end
---Set text buffer and mark dirty
---@param element Element? The parent element (for state saving)
---@param text string
---@param skipSanitization boolean? -- Skip sanitization (for trusted input)
function TextEditor:setText(element, text, skipSanitization)
text = text or ""
-- Sanitize text unless explicitly skipped
if not skipSanitization then
local originalText = text
text = self:_sanitizeText(text)
-- Trigger onSanitize callback if text was sanitized
if text ~= originalText and self.onSanitize and element then
self.onSanitize(element, originalText, text)
end
end
self._textBuffer = text
self:_markTextDirty()
self:_updateTextIfDirty(element)
self:_validateCursorPosition()
self:_saveState(element)
end
---Insert text at position
---@param element Element The parent element (for state saving)
---@param text string -- Text to insert
---@param position number? -- Position to insert at (default: cursor position)
---@param skipSanitization boolean? -- Skip sanitization (for internal use)
function TextEditor:insertText(element, text, position, skipSanitization)
position = position or self._cursorPosition
local buffer = self._textBuffer or ""
-- Sanitize text unless explicitly skipped
if not skipSanitization then
text = self:_sanitizeText(text)
end
-- Check if text is empty after sanitization
if not text or text == "" then
return
end
-- Check maxLength constraint before inserting
if self.maxLength then
local currentLength = utf8.len(buffer) or 0
local textLength = utf8.len(text) or 0
local newLength = currentLength + textLength
if newLength > self.maxLength then
-- Truncate text to fit
local remaining = self.maxLength - currentLength
if remaining <= 0 then
return
end
-- Truncate to remaining characters
local truncated = ""
local count = 0
for _, code in utf8.codes(text) do
if count >= remaining then
break
end
truncated = truncated .. utf8.char(code)
count = count + 1
end
text = truncated
end
end
-- Convert character position to byte offset
local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1)
-- Insert text
local before = buffer:sub(1, byteOffset - 1)
local after = buffer:sub(byteOffset)
self._textBuffer = before .. text .. after
self._cursorPosition = position + utf8.len(text)
self:_markTextDirty()
self:_updateTextIfDirty(element)
self:_validateCursorPosition()
self:_resetCursorBlink(element, true)
self:_saveState(element)
end
---Delete text in range
---@param element Element The parent element (for state saving)
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
function TextEditor:deleteText(element, startPos, endPos)
local buffer = self._textBuffer or ""
-- Ensure valid range
local textLength = utf8.len(buffer)
startPos = math.max(0, math.min(startPos, textLength))
endPos = math.max(0, math.min(endPos, textLength))
if startPos > endPos then
startPos, endPos = endPos, startPos
end
-- Convert character positions to byte offsets
local startByte = utf8.offset(buffer, startPos + 1) or 1
local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1)
-- Delete text
local before = buffer:sub(1, startByte - 1)
local after = buffer:sub(endByte)
self._textBuffer = before .. after
self:_markTextDirty()
self:_updateTextIfDirty(element)
self:_resetCursorBlink(element, true)
self:_saveState(element)
end
---Replace text in range
---@param element Element The parent element (for state saving)
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
---@param newText string -- Replacement text
function TextEditor:replaceText(element, startPos, endPos, newText)
self:deleteText(element, startPos, endPos)
self:insertText(element, newText, startPos)
end
---Mark text as dirty (needs recalculation)
function TextEditor:_markTextDirty()
self._textDirty = true
end
---Update text if dirty (recalculate lines and wrapping)
---@param element Element? The parent element (for wrapping calculations)
function TextEditor:_updateTextIfDirty(element)
if not self._textDirty then
return
end
self:_splitLines()
self:_calculateWrapping(element)
self:_validateCursorPosition()
self._textDirty = false
end
-- ====================
-- Line Splitting and Wrapping
-- ====================
---Split text into lines (for multi-line text)
function TextEditor:_splitLines()
if not self.multiline then
self._lines = { self._textBuffer or "" }
return
end
self._lines = {}
local text = self._textBuffer or ""
-- Split on newlines
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(self._lines, line)
end
-- Ensure at least one line
if #self._lines == 0 then
self._lines = { "" }
end
end
---Calculate text wrapping
---@param element Element? The parent element
function TextEditor:_calculateWrapping(element)
if not self.textWrap or not element then
self._wrappedLines = nil
return
end
self._wrappedLines = {}
local availableWidth = element.width - element.padding.left - element.padding.right
for lineNum, line in ipairs(self._lines or {}) do
if line == "" then
table.insert(self._wrappedLines, {
text = "",
startIdx = 0,
endIdx = 0,
lineNum = lineNum,
})
else
local wrappedParts = self:_wrapLine(element, line, availableWidth)
for _, part in ipairs(wrappedParts) do
part.lineNum = lineNum
table.insert(self._wrappedLines, part)
end
end
end
end
---Wrap a single line of text
---@param element Element The parent element
---@param line string -- Line to wrap
---@param maxWidth number -- Maximum width in pixels
---@return table -- Array of wrapped line parts
function TextEditor:_wrapLine(element, line, maxWidth)
if not element then
return { { text = line, startIdx = 0, endIdx = utf8.len(line) } }
end
-- Delegate to Renderer
return element._renderer:wrapLine(element, line, maxWidth)
end
-- ====================
-- Cursor Management
-- ====================
---Set cursor position
---@param element Element? The parent element (for scroll updates)
---@param position number -- Character index (0-based)
function TextEditor:setCursorPosition(element, position)
self._cursorPosition = position
self:_validateCursorPosition()
self:_resetCursorBlink(element)
end
---Get cursor position
---@return number -- Character index (0-based)
function TextEditor:getCursorPosition()
return self._cursorPosition
end
---Move cursor by delta characters
---@param element Element? The parent element (for scroll updates)
---@param delta number -- Number of characters to move (positive or negative)
function TextEditor:moveCursorBy(element, delta)
self._cursorPosition = self._cursorPosition + delta
self:_validateCursorPosition()
self:_resetCursorBlink(element)
end
---Move cursor to start of text
---@param element Element? The parent element (for scroll updates)
function TextEditor:moveCursorToStart(element)
self._cursorPosition = 0
self:_resetCursorBlink(element)
end
---Move cursor to end of text
---@param element Element? The parent element (for scroll updates)
function TextEditor:moveCursorToEnd(element)
local textLength = utf8.len(self._textBuffer or "")
self._cursorPosition = textLength
self:_resetCursorBlink(element)
end
---Move cursor to start of current line
---@param element Element? The parent element (for scroll updates)
function TextEditor:moveCursorToLineStart(element)
-- For now, just move to start (will be enhanced for multi-line)
self:moveCursorToStart(element)
end
---Move cursor to end of current line
---@param element Element? The parent element (for scroll updates)
function TextEditor:moveCursorToLineEnd(element)
-- For now, just move to end (will be enhanced for multi-line)
self:moveCursorToEnd(element)
end
---Move cursor to start of previous word
function TextEditor:moveCursorToPreviousWord()
if not self._textBuffer then
return
end
local text = self._textBuffer
local pos = self._cursorPosition
if pos <= 0 then
return
end
-- Helper function to get character at position
local function getCharAt(p)
if p < 0 or p >= utf8.len(text) then
return nil
end
local offset1 = utf8.offset(text, p + 1)
local offset2 = utf8.offset(text, p + 2)
if not offset1 then
return nil
end
if not offset2 then
return text:sub(offset1)
end
return text:sub(offset1, offset2 - 1)
end
-- Skip any whitespace/punctuation before current position
while pos > 0 do
local char = getCharAt(pos - 1)
if char and char:match("[%w]") then
break
end
pos = pos - 1
end
-- Move to start of current word
while pos > 0 do
local char = getCharAt(pos - 1)
if not char or not char:match("[%w]") then
break
end
pos = pos - 1
end
self._cursorPosition = pos
self:_validateCursorPosition()
end
---Move cursor to start of next word
function TextEditor:moveCursorToNextWord()
if not self._textBuffer then
return
end
local text = self._textBuffer
local textLength = utf8.len(text) or 0
local pos = self._cursorPosition
if pos >= textLength then
return
end
-- Helper function to get character at position
local function getCharAt(p)
if p < 0 or p >= textLength then
return nil
end
local offset1 = utf8.offset(text, p + 1)
local offset2 = utf8.offset(text, p + 2)
if not offset1 then
return nil
end
if not offset2 then
return text:sub(offset1)
end
return text:sub(offset1, offset2 - 1)
end
-- Skip current word
while pos < textLength do
local char = getCharAt(pos)
if not char or not char:match("[%w]") then
break
end
pos = pos + 1
end
-- Skip any whitespace/punctuation
while pos < textLength do
local char = getCharAt(pos)
if char and char:match("[%w]") then
break
end
pos = pos + 1
end
self._cursorPosition = pos
self:_validateCursorPosition()
end
---Validate cursor position (ensure it's within text bounds)
function TextEditor:_validateCursorPosition()
local textLength = utf8.len(self._textBuffer or "") or 0
local cursorPos = tonumber(self._cursorPosition) or 0
self._cursorPosition = math.max(0, math.min(cursorPos, textLength))
end
---Reset cursor blink (show cursor immediately)
---@param element Element? The parent element (for scroll updates)
---@param pauseBlink boolean|nil -- Whether to pause blinking (for typing)
function TextEditor:_resetCursorBlink(element, pauseBlink)
self._cursorBlinkTimer = 0
self._cursorVisible = true
if pauseBlink then
self._cursorBlinkPaused = true
self._cursorBlinkPauseTimer = 0
end
self:_updateTextScroll(element)
end
---Update text scroll offset to keep cursor visible
---@param element Element? The parent element
function TextEditor:_updateTextScroll(element)
if not element or self.multiline then
return
end
local font = self:_getFont(element)
if not font then
return
end
-- Calculate cursor X position in text coordinates
local cursorText = ""
if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then
local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1)
if byteOffset then
cursorText = self._textBuffer:sub(1, byteOffset - 1)
end
end
local cursorX = font:getWidth(cursorText)
-- Get available text area width
local textAreaWidth = element.width
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
end
-- Add some padding on the right for the cursor
local cursorPadding = 4
local visibleWidth = textAreaWidth - cursorPadding
-- Adjust scroll to keep cursor visible
if cursorX - self._textScrollX < 0 then
self._textScrollX = cursorX
elseif cursorX - self._textScrollX > visibleWidth then
self._textScrollX = cursorX - visibleWidth
end
-- Ensure we don't scroll past the beginning
self._textScrollX = math.max(0, self._textScrollX)
end
---Get cursor screen position for rendering (handles multiline text)
---@param element Element? The parent element
---@return number, number -- Cursor X and Y position relative to content area
function TextEditor:_getCursorScreenPosition(element)
local font = self:_getFont(element)
if not font then
return 0, 0
end
local text = self._textBuffer or ""
local cursorPos = self._cursorPosition or 0
-- Apply password masking for cursor position calculation
local textForMeasurement = text
if self.passwordMode and text ~= "" then
textForMeasurement = string.rep("", utf8.len(text))
end
-- For single-line text, calculate simple X position
if not self.multiline then
local cursorText = ""
if textForMeasurement ~= "" and cursorPos > 0 then
local byteOffset = utf8.offset(textForMeasurement, cursorPos + 1)
if byteOffset then
cursorText = textForMeasurement:sub(1, byteOffset - 1)
end
end
return font:getWidth(cursorText), 0
end
-- For multiline text, we need to find which wrapped line the cursor is on
self:_updateTextIfDirty(element)
if not element then
return 0, 0
end
-- Get text area width for wrapping
local textAreaWidth = element.width
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
end
-- Split text by actual newlines first
local lines = {}
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
lines = { "" }
end
-- Track character position as we iterate through lines
local charCount = 0
local cursorX = 0
local cursorY = 0
local lineHeight = font:getHeight()
for lineNum, line in ipairs(lines) do
local lineLength = utf8.len(line) or 0
-- Check if cursor is on this line
if cursorPos <= charCount + lineLength then
local posInLine = cursorPos - charCount
-- If text wrapping is enabled, find which wrapped segment
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(element, line, textAreaWidth)
for segmentIdx, segment in ipairs(wrappedSegments) do
if posInLine >= segment.startIdx and posInLine <= segment.endIdx then
local posInSegment = posInLine - segment.startIdx
local segmentText = ""
if posInSegment > 0 and segment.text ~= "" then
local endByte = utf8.offset(segment.text, posInSegment + 1)
if endByte then
segmentText = segment.text:sub(1, endByte - 1)
else
segmentText = segment.text
end
end
cursorX = font:getWidth(segmentText)
cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight
return cursorX, cursorY
end
end
else
-- No wrapping, simple calculation
local lineText = ""
if posInLine > 0 then
local endByte = utf8.offset(line, posInLine + 1)
if endByte then
lineText = line:sub(1, endByte - 1)
else
lineText = line
end
end
cursorX = font:getWidth(lineText)
cursorY = (lineNum - 1) * lineHeight
return cursorX, cursorY
end
end
charCount = charCount + lineLength + 1
end
-- Cursor is at the very end
return 0, #lines * lineHeight
end
-- ====================
-- Selection Management
-- ====================
---Set selection range
---@param element Element? The parent element (for scroll updates)
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
function TextEditor:setSelection(element, startPos, endPos)
local textLength = utf8.len(self._textBuffer or "")
self._selectionStart = math.max(0, math.min(startPos, textLength))
self._selectionEnd = math.max(0, math.min(endPos, textLength))
-- Ensure start <= end
if self._selectionStart > self._selectionEnd then
self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart
end
self:_resetCursorBlink(element)
end
---Get selection range
---@return number?, number? -- Start and end positions, or nil if no selection
function TextEditor:getSelection()
if not self:hasSelection() then
return nil, nil
end
return self._selectionStart, self._selectionEnd
end
---Check if there is an active selection
---@return boolean
function TextEditor:hasSelection()
return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd
end
---Clear selection
function TextEditor:clearSelection()
self._selectionStart = nil
self._selectionEnd = nil
self._selectionAnchor = nil
end
---Select all text
---@param element Element? The parent element (for scroll updates)
function TextEditor:selectAll(element)
local textLength = utf8.len(self._textBuffer or "")
self._selectionStart = 0
self._selectionEnd = textLength
self:_resetCursorBlink(element)
end
---Get selected text
---@return string? -- Selected text or nil if no selection
function TextEditor:getSelectedText()
if not self:hasSelection() then
return nil
end
local startPos, endPos = self:getSelection()
if not startPos or not endPos then
return nil
end
-- Convert character indices to byte offsets
local text = self._textBuffer or ""
local startByte = utf8.offset(text, startPos + 1)
local endByte = utf8.offset(text, endPos + 1)
if not startByte then
return ""
end
if endByte then
endByte = endByte - 1
end
return string.sub(text, startByte, endByte)
end
---Delete selected text
---@param element Element The parent element (for state saving)
---@return boolean -- True if text was deleted
function TextEditor:deleteSelection(element)
if not self:hasSelection() then
return false
end
local startPos, endPos = self:getSelection()
if not startPos or not endPos then
return false
end
self:deleteText(element, startPos, endPos)
self:clearSelection()
self._cursorPosition = startPos
self:_validateCursorPosition()
self:_saveState(element)
return true
end
---Get selection rectangles for rendering
---@param element Element The parent element
---@param selStart number -- Selection start position
---@param selEnd number -- Selection end position
---@return table -- Array of rectangles {x, y, width, height}
function TextEditor:_getSelectionRects(element, selStart, selEnd)
local font = self:_getFont(element)
if not font or not element then
return {}
end
local text = self._textBuffer or ""
local rects = {}
-- Apply password masking
local textForMeasurement = text
if self.passwordMode and text ~= "" then
textForMeasurement = string.rep("", utf8.len(text))
end
-- For single-line text, calculate simple rectangle
if not self.multiline then
local startByte = utf8.offset(textForMeasurement, selStart + 1)
local endByte = utf8.offset(textForMeasurement, selEnd + 1)
if startByte and endByte then
local beforeSelection = textForMeasurement:sub(1, startByte - 1)
local selectedText = textForMeasurement:sub(startByte, endByte - 1)
local selX = font:getWidth(beforeSelection)
local selWidth = font:getWidth(selectedText)
local selY = 0
local selHeight = font:getHeight()
table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight })
end
return rects
end
-- For multiline text, handle line wrapping
self:_updateTextIfDirty(element)
-- Get text area width for wrapping
local textAreaWidth = element.width
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
end
-- Split text by actual newlines
local lines = {}
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
lines = { "" }
end
local lineHeight = font:getHeight()
local charCount = 0
local visualLineNum = 0
for lineNum, line in ipairs(lines) do
local lineLength = utf8.len(line) or 0
local lineStartChar = charCount
local lineEndChar = charCount + lineLength
if selEnd > lineStartChar and selStart <= lineEndChar then
local selStartInLine = math.max(0, selStart - charCount)
local selEndInLine = math.min(lineLength, selEnd - charCount)
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(element, line, textAreaWidth)
for segmentIdx, segment in ipairs(wrappedSegments) do
if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then
local segSelStart = math.max(segment.startIdx, selStartInLine)
local segSelEnd = math.min(segment.endIdx, selEndInLine)
local beforeText = ""
local selectedText = ""
if segSelStart > segment.startIdx then
local startByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1)
if startByte then
beforeText = segment.text:sub(1, startByte - 1)
end
end
local selStartByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1)
local selEndByte = utf8.offset(segment.text, segSelEnd - segment.startIdx + 1)
if selStartByte and selEndByte then
selectedText = segment.text:sub(selStartByte, selEndByte - 1)
end
local selX = font:getWidth(beforeText)
local selWidth = font:getWidth(selectedText)
local selY = visualLineNum * lineHeight
local selHeight = lineHeight
table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight })
end
visualLineNum = visualLineNum + 1
end
else
-- No wrapping
local beforeText = ""
local selectedText = ""
if selStartInLine > 0 then
local startByte = utf8.offset(line, selStartInLine + 1)
if startByte then
beforeText = line:sub(1, startByte - 1)
end
end
local selStartByte = utf8.offset(line, selStartInLine + 1)
local selEndByte = utf8.offset(line, selEndInLine + 1)
if selStartByte and selEndByte then
selectedText = line:sub(selStartByte, selEndByte - 1)
end
local selX = font:getWidth(beforeText)
local selWidth = font:getWidth(selectedText)
local selY = visualLineNum * lineHeight
local selHeight = lineHeight
table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight })
visualLineNum = visualLineNum + 1
end
else
-- Selection doesn't intersect, but count visual lines
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(element, line, textAreaWidth)
visualLineNum = visualLineNum + #wrappedSegments
else
visualLineNum = visualLineNum + 1
end
end
charCount = charCount + lineLength + 1
end
return rects
end
-- ====================
-- Focus Management
-- ====================
---Focus this element for keyboard input
---@param element Element The parent element
function TextEditor:focus(element)
if not element then
return
end
if self._Context._focusedElement and self._Context._focusedElement ~= element then
-- Blur the previously focused element's text editor if it has one
if self._Context._focusedElement._textEditor then
self._Context._focusedElement._textEditor:blur(self._Context._focusedElement)
end
end
self._focused = true
self._Context._focusedElement = element
self:_resetCursorBlink(element)
if self.selectOnFocus then
self:selectAll(element)
else
self:moveCursorToEnd(element)
end
if self.onFocus then
self.onFocus(element)
end
self:_saveState(element)
end
---Remove focus from this element
---@param element Element The parent element
function TextEditor:blur(element)
if not element then
return
end
self._focused = false
if self._Context._focusedElement == element then
self._Context._focusedElement = nil
end
if self.onBlur then
self.onBlur(element)
end
self:_saveState(element)
end
---Check if this element is focused
---@return boolean
function TextEditor:isFocused()
return self._focused == true
end
-- ====================
-- Input Handling
-- ====================
---Handle text input (character insertion)
---@param element Element The parent element
---@param text string
function TextEditor:handleTextInput(element, text)
if not self._focused then
return
end
-- Trigger onTextInput callback if defined
if self.onTextInput then
local result = self.onTextInput(element, text)
if result == false then
return
end
end
local oldText = self._textBuffer
-- Delete selection if exists
if self:hasSelection() then
self:deleteSelection(element)
end
-- Insert text at cursor position
self:insertText(element, text)
-- Trigger onTextChange callback
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
self:_saveState(element)
end
---Handle key press (special keys)
---@param element Element The parent element
---@param key string -- Key name
---@param scancode string -- Scancode
---@param isrepeat boolean -- Whether this is a key repeat
function TextEditor:handleKeyPress(element, key, scancode, isrepeat)
if not self._focused then
return
end
local modifiers = self._getModifiers()
local ctrl = modifiers.ctrl or modifiers.super
-- Handle cursor movement with selection
if key == "left" or key == "right" or key == "home" or key == "end" or key == "up" or key == "down" then
if modifiers.shift and not self._selectionAnchor then
self._selectionAnchor = self._cursorPosition
end
if key == "left" then
if modifiers.super then
self:moveCursorToStart(element)
if not modifiers.shift then
self:clearSelection()
end
elseif modifiers.alt then
self:moveCursorToPreviousWord()
elseif self:hasSelection() and not modifiers.shift then
local startPos, _ = self:getSelection()
self._cursorPosition = startPos
self:clearSelection()
else
self:moveCursorBy(element, -1)
end
elseif key == "right" then
if modifiers.super then
self:moveCursorToEnd(element)
if not modifiers.shift then
self:clearSelection()
end
elseif modifiers.alt then
self:moveCursorToNextWord()
elseif self:hasSelection() and not modifiers.shift then
local _, endPos = self:getSelection()
self._cursorPosition = endPos
self:clearSelection()
else
self:moveCursorBy(element, 1)
end
elseif key == "home" then
if not self.multiline then
self:moveCursorToStart(element)
else
self:moveCursorToLineStart(element)
end
if not modifiers.shift then
self:clearSelection()
end
elseif key == "end" then
if not self.multiline then
self:moveCursorToEnd(element)
else
self:moveCursorToLineEnd(element)
end
if not modifiers.shift then
self:clearSelection()
end
elseif key == "up" or key == "down" then
if not modifiers.shift then
self:clearSelection()
end
end
-- Update selection if Shift is pressed
if modifiers.shift and self._selectionAnchor then
self:setSelection(element, self._selectionAnchor, self._cursorPosition)
elseif not modifiers.shift then
self._selectionAnchor = nil
end
self:_resetCursorBlink(element)
-- Handle backspace and delete
elseif key == "backspace" then
local oldText = self._textBuffer
if self:hasSelection() then
self:deleteSelection(element)
elseif ctrl then
if self._cursorPosition > 0 then
self:deleteText(element, 0, self._cursorPosition)
self._cursorPosition = 0
self:_validateCursorPosition()
end
elseif self._cursorPosition > 0 then
local deleteStart = self._cursorPosition - 1
local deleteEnd = self._cursorPosition
self._cursorPosition = deleteStart
self:deleteText(element, deleteStart, deleteEnd)
self:_validateCursorPosition()
end
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
self:_resetCursorBlink(element, true)
elseif key == "delete" then
local oldText = self._textBuffer
if self:hasSelection() then
self:deleteSelection(element)
else
local textLength = utf8.len(self._textBuffer or "")
if self._cursorPosition < textLength then
self:deleteText(element, self._cursorPosition, self._cursorPosition + 1)
end
end
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
self:_resetCursorBlink(element, true)
-- Handle return/enter
elseif key == "return" or key == "kpenter" then
if self.multiline then
local oldText = self._textBuffer
if self:hasSelection() then
self:deleteSelection(element)
end
self:insertText(element, "\n")
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
else
if self.onEnter then
self.onEnter(element)
end
end
self:_resetCursorBlink(element, true)
-- Handle Ctrl/Cmd+A (select all)
elseif ctrl and key == "a" then
self:selectAll(element)
self:_resetCursorBlink(element)
-- Handle Ctrl/Cmd+C (copy)
elseif ctrl and key == "c" then
if self:hasSelection() then
local selectedText = self:getSelectedText()
if selectedText then
love.system.setClipboardText(selectedText)
end
end
self:_resetCursorBlink(element)
-- Handle Ctrl/Cmd+X (cut)
elseif ctrl and key == "x" then
if self:hasSelection() then
local selectedText = self:getSelectedText()
if selectedText then
love.system.setClipboardText(selectedText)
local oldText = self._textBuffer
self:deleteSelection(element)
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
end
end
self:_resetCursorBlink(element, true)
-- Handle Ctrl/Cmd+V (paste)
elseif ctrl and key == "v" then
local clipboardText = love.system.getClipboardText()
if clipboardText and clipboardText ~= "" then
local oldText = self._textBuffer
if self:hasSelection() then
self:deleteSelection(element)
end
self:insertText(element, clipboardText)
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(element, self._textBuffer, oldText)
end
end
self:_resetCursorBlink(element, true)
-- Handle Escape
elseif key == "escape" then
if self:hasSelection() then
self:clearSelection()
else
self:blur(element)
end
self:_resetCursorBlink(element)
end
self:_saveState(element)
end
-- ====================
-- Mouse Input
-- ====================
---Convert mouse coordinates to cursor position in text
---@param element Element The parent element
---@param mouseX number -- Mouse X coordinate (absolute)
---@param mouseY number -- Mouse Y coordinate (absolute)
---@return number -- Cursor position (character index)
function TextEditor:mouseToTextPosition(element, mouseX, mouseY)
if not element or not self._textBuffer then
return 0
end
local font = self:_getFont(element)
if not font then
return 0
end
-- Get content area bounds
local contentX = (element._absoluteX or element.x) + element.padding.left
local contentY = (element._absoluteY or element.y) + element.padding.top
-- Calculate relative position
local relativeX = mouseX - contentX
local relativeY = mouseY - contentY
local text = self._textBuffer
local textLength = utf8.len(text) or 0
-- Single-line handling
if not self.multiline then
if self._textScrollX then
relativeX = relativeX + self._textScrollX
end
local closestPos = 0
local closestDist = math.huge
for i = 0, textLength do
local offset = utf8.offset(text, i + 1)
local beforeText = offset and text:sub(1, offset - 1) or text
local textWidth = font:getWidth(beforeText)
local dist = math.abs(relativeX - textWidth)
if dist < closestDist then
closestDist = dist
closestPos = i
end
end
return closestPos
end
-- Multiline handling
self:_updateTextIfDirty(element)
-- Split text into lines
local lines = {}
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
lines = { "" }
end
local lineHeight = font:getHeight()
-- Get text area width
local textAreaWidth = element.width
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
end
-- Determine which line was clicked
local clickedLineNum = math.floor(relativeY / lineHeight) + 1
clickedLineNum = math.max(1, math.min(clickedLineNum, #lines))
-- Calculate character offset for lines before clicked line
local charOffset = 0
for i = 1, clickedLineNum - 1 do
local lineLen = utf8.len(lines[i]) or 0
charOffset = charOffset + lineLen + 1
end
local clickedLine = lines[clickedLineNum]
local lineLen = utf8.len(clickedLine) or 0
-- Handle wrapped segments
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(element, clickedLine, textAreaWidth)
local lineYOffset = (clickedLineNum - 1) * lineHeight
local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1
segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments))
local segment = wrappedSegments[segmentNum]
local segmentText = segment.text
local segmentLen = utf8.len(segmentText) or 0
local closestPos = segment.startIdx
local closestDist = math.huge
for i = 0, segmentLen do
local offset = utf8.offset(segmentText, i + 1)
local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText
local textWidth = font:getWidth(beforeText)
local dist = math.abs(relativeX - textWidth)
if dist < closestDist then
closestDist = dist
closestPos = segment.startIdx + i
end
end
return charOffset + closestPos
end
-- No wrapping
local closestPos = 0
local closestDist = math.huge
for i = 0, lineLen do
local offset = utf8.offset(clickedLine, i + 1)
local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine
local textWidth = font:getWidth(beforeText)
local dist = math.abs(relativeX - textWidth)
if dist < closestDist then
closestDist = dist
closestPos = i
end
end
return charOffset + closestPos
end
---Handle mouse click on text
---@param element Element The parent element
---@param mouseX number
---@param mouseY number
---@param clickCount number -- 1=single, 2=double, 3=triple
function TextEditor:handleTextClick(element, mouseX, mouseY, clickCount)
if not self._focused then
return
end
if clickCount == 1 then
local pos = self:mouseToTextPosition(element, mouseX, mouseY)
self:setCursorPosition(element, pos)
self:clearSelection()
self._mouseDownPosition = pos
elseif clickCount == 2 then
self:_selectWordAtPosition(element, self:mouseToTextPosition(element, mouseX, mouseY))
elseif clickCount >= 3 then
self:selectAll(element)
end
self:_resetCursorBlink(element)
end
---Handle mouse drag for text selection
---@param element Element The parent element
---@param mouseX number
---@param mouseY number
function TextEditor:handleTextDrag(element, mouseX, mouseY)
if not self._focused or not element._mouseDownPosition then
return
end
local currentPos = self:mouseToTextPosition(element, mouseX, mouseY)
if currentPos ~= element._mouseDownPosition then
self:setSelection(element, element._mouseDownPosition, currentPos)
self._cursorPosition = currentPos
self._textDragOccurred = true
else
self:clearSelection()
end
self:_resetCursorBlink(element)
end
---Select word at given position
---@param element Element? The parent element (for scroll updates)
---@param position number
function TextEditor:_selectWordAtPosition(element, position)
if not self._textBuffer then
return
end
local text = self._textBuffer
local textLength = utf8.len(text) or 0
if textLength == 0 then
return
end
-- Helper to get character at position
local function getCharAt(p)
if p < 0 or p >= textLength then
return nil
end
local offset1 = utf8.offset(text, p + 1)
local offset2 = utf8.offset(text, p + 2)
if not offset1 then
return nil
end
if not offset2 then
return text:sub(offset1)
end
return text:sub(offset1, offset2 - 1)
end
-- Find word boundaries
local startPos = position
local endPos = position
-- Expand left to start of word
while startPos > 0 do
local char = getCharAt(startPos - 1)
if not char or not char:match("[%w]") then
break
end
startPos = startPos - 1
end
-- Expand right to end of word
while endPos < textLength do
local char = getCharAt(endPos)
if not char or not char:match("[%w]") then
break
end
endPos = endPos + 1
end
self:setSelection(element, startPos, endPos)
self._cursorPosition = endPos
end
-- ====================
-- Update and Rendering
-- ====================
---Update cursor blink animation
---@param element Element The parent element
---@param dt number -- Delta time
function TextEditor:update(element, dt)
if not self._focused then
return
end
-- Update cursor blink
if self._cursorBlinkPaused then
self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt
if self._cursorBlinkPauseTimer >= 0.5 then
self._cursorBlinkPaused = false
self._cursorBlinkPauseTimer = 0
end
else
self._cursorBlinkTimer = self._cursorBlinkTimer + dt
if self._cursorBlinkTimer >= self.cursorBlinkRate then
self._cursorBlinkTimer = 0
self._cursorVisible = not self._cursorVisible
end
end
-- Save state for immediate mode (cursor blink timer changes need to persist)
self:_saveState(element)
end
---Update element height based on text content (for autoGrow)
---@param element Element The parent element
function TextEditor:updateAutoGrowHeight(element)
if not self.multiline or not self.autoGrow or not element then
return
end
local font = self:_getFont(element)
if not font then
return
end
local text = self._textBuffer or ""
local lineHeight = font:getHeight()
-- Get text area width
local textAreaWidth = element.width
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
end
-- Split text by newlines
local lines = {}
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
lines = { "" }
end
-- Count total wrapped lines
local totalWrappedLines = 0
if self.textWrap and textAreaWidth > 0 then
for _, line in ipairs(lines) do
if line == "" then
totalWrappedLines = totalWrappedLines + 1
else
local wrappedSegments = self:_wrapLine(element, line, textAreaWidth)
totalWrappedLines = totalWrappedLines + #wrappedSegments
end
end
else
totalWrappedLines = #lines
end
totalWrappedLines = math.max(1, totalWrappedLines)
local newContentHeight = totalWrappedLines * lineHeight
if element.height ~= newContentHeight then
element.height = newContentHeight
element._borderBoxHeight = element.height + element.padding.top + element.padding.bottom
if element.parent and not element._explicitlyAbsolute then
element.parent:layoutChildren()
end
end
end
-- ====================
-- Helper Methods
-- ====================
---Get font for text rendering
---@param element Element? The parent element
---@return love.Font?
function TextEditor:_getFont(element)
if not element then
return nil
end
-- Delegate to Renderer
return element._renderer:getFont(element)
end
--- Get current state for persistence
---@return table state TextEditor state snapshot
function TextEditor:getState()
return {
_cursorPosition = self._cursorPosition,
_selectionStart = self._selectionStart,
_selectionEnd = self._selectionEnd,
_textBuffer = self._textBuffer,
_cursorBlinkTimer = self._cursorBlinkTimer,
_cursorVisible = self._cursorVisible,
_cursorBlinkPaused = self._cursorBlinkPaused,
_cursorBlinkPauseTimer = self._cursorBlinkPauseTimer,
_focused = self._focused,
}
end
--- Restore state from persistence
---@param state table State to restore
---@param element Element? The parent element (needed for focus restoration)
function TextEditor:setState(state, element)
if not state then
return
end
if state._cursorPosition ~= nil then
self._cursorPosition = state._cursorPosition
end
if state._selectionStart ~= nil then
self._selectionStart = state._selectionStart
end
if state._selectionEnd ~= nil then
self._selectionEnd = state._selectionEnd
end
if state._textBuffer ~= nil then
self._textBuffer = state._textBuffer
end
if state._cursorBlinkTimer ~= nil then
self._cursorBlinkTimer = state._cursorBlinkTimer
end
if state._cursorVisible ~= nil then
self._cursorVisible = state._cursorVisible
end
if state._cursorBlinkPaused ~= nil then
self._cursorBlinkPaused = state._cursorBlinkPaused
end
if state._cursorBlinkPauseTimer ~= nil then
self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer
end
if state._focused ~= nil then
self._focused = state._focused
-- Restore focused element in Context if this element was focused
if self._focused and element then
self._Context._focusedElement = element
end
end
end
---Save state to StateManager (for immediate mode)
---@param element Element? The parent element
function TextEditor:_saveState(element)
if not element or not element._stateId or not self._Context._immediateMode then
return
end
-- Get current state (may have other sub-modules like eventHandler, scrollManager)
local currentState = self._StateManager.getState(element._stateId) or {}
-- Update only the textEditor sub-table to match the nested structure
-- used by element:saveState() at endFrame
currentState.textEditor = {
_focused = self._focused,
_textBuffer = self._textBuffer,
_cursorPosition = self._cursorPosition,
_selectionStart = self._selectionStart,
_selectionEnd = self._selectionEnd,
_cursorBlinkTimer = self._cursorBlinkTimer,
_cursorVisible = self._cursorVisible,
_cursorBlinkPaused = self._cursorBlinkPaused,
_cursorBlinkPauseTimer = self._cursorBlinkPauseTimer,
}
self._StateManager.updateState(element._stateId, currentState)
end
return TextEditor