local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8") local utf8 = 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