-- ==================== -- TextEditor Module -- ==================== -- Handles all text editing functionality including: -- - Text buffer management -- - Cursor positioning and navigation -- - Text selection -- - Multi-line text with wrapping -- - Focus management -- - Keyboard input handling -- - Text rendering (cursor, selection highlights) --- --- Dependencies (must be injected via deps parameter): --- - GuiState: GUI state manager --- - StateManager: State persistence for immediate mode --- - Color: Color utility class (reserved for future use) --- - utils: Utility functions (FONT_CACHE, getModifiers) -- Setup module path for relative requires local modulePath = (...):match("(.-)[^%.]+$") local function req(name) return require(modulePath .. name) end -- UTF-8 support local utf8 = utf8 or require("utf8") 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 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 {GuiState, StateManager, Color, utils} ---@return table TextEditor instance function TextEditor.new(config, deps) -- Pure DI: Dependencies must be injected assert(deps, "TextEditor.new: deps parameter is required") assert(deps.GuiState, "TextEditor.new: deps.GuiState is required") assert(deps.StateManager, "TextEditor.new: deps.StateManager is required") assert(deps.Color, "TextEditor.new: deps.Color is required") assert(deps.utils, "TextEditor.new: deps.utils is required") local self = setmetatable({}, TextEditor) -- Store dependencies self._GuiState = deps.GuiState self._StateManager = deps.StateManager self._Color = deps.Color self._FONT_CACHE = deps.utils.FONT_CACHE self._getModifiers = deps.utils.getModifiers -- 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 -- Initialize text buffer state self._textBuffer = config.text or "" 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 -- Element reference (set via initialize) self._element = nil return self end ---Initialize TextEditor with parent element reference ---@param element table The parent Element instance function TextEditor:initialize(element) self._element = element -- Restore state from StateManager if in immediate mode if element._stateId and self._GuiState._immediateMode then local state = self._StateManager.getState(element._stateId) if state then if state._focused then self._focused = true self._GuiState._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 text string function TextEditor:setText(text) self._textBuffer = text or "" self:_markTextDirty() self:_updateTextIfDirty() self:_validateCursorPosition() self:_saveState() end ---Insert text at position ---@param text string -- Text to insert ---@param position number? -- Position to insert at (default: cursor position) function TextEditor:insertText(text, position) position = position or self._cursorPosition local buffer = self._textBuffer or "" -- 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 return 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() self:_validateCursorPosition() self:_resetCursorBlink(true) self:_saveState() end ---Delete text in range ---@param startPos number -- Start position (inclusive) ---@param endPos number -- End position (inclusive) function TextEditor:deleteText(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() self:_resetCursorBlink(true) self:_saveState() end ---Replace text in range ---@param startPos number -- Start position (inclusive) ---@param endPos number -- End position (inclusive) ---@param newText string -- Replacement text function TextEditor:replaceText(startPos, endPos, newText) self:deleteText(startPos, endPos) self:insertText(newText, startPos) end ---Mark text as dirty (needs recalculation) function TextEditor:_markTextDirty() self._textDirty = true end ---Update text if dirty (recalculate lines and wrapping) function TextEditor:_updateTextIfDirty() if not self._textDirty then return end self:_splitLines() self:_calculateWrapping() 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 function TextEditor:_calculateWrapping() if not self.textWrap or not self._element then self._wrappedLines = nil return end self._wrappedLines = {} local availableWidth = self._element.width - self._element.padding.left - self._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(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 line string -- Line to wrap ---@param maxWidth number -- Maximum width in pixels ---@return table -- Array of wrapped line parts function TextEditor:_wrapLine(line, maxWidth) if not self._element then return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } end -- Delegate to Renderer return self._element._renderer:wrapLine(self._element, line, maxWidth) end -- ==================== -- Cursor Management -- ==================== ---Set cursor position ---@param position number -- Character index (0-based) function TextEditor:setCursorPosition(position) self._cursorPosition = position self:_validateCursorPosition() self:_resetCursorBlink() end ---Get cursor position ---@return number -- Character index (0-based) function TextEditor:getCursorPosition() return self._cursorPosition end ---Move cursor by delta characters ---@param delta number -- Number of characters to move (positive or negative) function TextEditor:moveCursorBy(delta) self._cursorPosition = self._cursorPosition + delta self:_validateCursorPosition() self:_resetCursorBlink() end ---Move cursor to start of text function TextEditor:moveCursorToStart() self._cursorPosition = 0 self:_resetCursorBlink() end ---Move cursor to end of text function TextEditor:moveCursorToEnd() local textLength = utf8.len(self._textBuffer or "") self._cursorPosition = textLength self:_resetCursorBlink() end ---Move cursor to start of current line function TextEditor:moveCursorToLineStart() -- For now, just move to start (will be enhanced for multi-line) self:moveCursorToStart() end ---Move cursor to end of current line function TextEditor:moveCursorToLineEnd() -- For now, just move to end (will be enhanced for multi-line) self:moveCursorToEnd() 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 pauseBlink boolean|nil -- Whether to pause blinking (for typing) function TextEditor:_resetCursorBlink(pauseBlink) self._cursorBlinkTimer = 0 self._cursorVisible = true if pauseBlink then self._cursorBlinkPaused = true self._cursorBlinkPauseTimer = 0 end self:_updateTextScroll() end ---Update text scroll offset to keep cursor visible function TextEditor:_updateTextScroll() if not self._element or self.multiline then return end local font = self:_getFont() 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 = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() if scaledContentPadding then local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._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) ---@return number, number -- Cursor X and Y position relative to content area function TextEditor:_getCursorScreenPosition() local font = self:_getFont() 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() if not self._element then return 0, 0 end -- Get text area width for wrapping local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() if scaledContentPadding then local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._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(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 startPos number -- Start position (inclusive) ---@param endPos number -- End position (inclusive) function TextEditor:setSelection(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() 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 function TextEditor:selectAll() local textLength = utf8.len(self._textBuffer or "") self._selectionStart = 0 self._selectionEnd = textLength self:_resetCursorBlink() 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 ---@return boolean -- True if text was deleted function TextEditor:deleteSelection() 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(startPos, endPos) self:clearSelection() self._cursorPosition = startPos self:_validateCursorPosition() self:_saveState() return true end ---Get selection rectangles for rendering ---@param selStart number -- Selection start position ---@param selEnd number -- Selection end position ---@return table -- Array of rectangles {x, y, width, height} function TextEditor:_getSelectionRects(selStart, selEnd) local font = self:_getFont() if not font or not self._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() -- Get text area width for wrapping local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() if scaledContentPadding then local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._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(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(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 function TextEditor:focus() if self._GuiState._focusedElement and self._GuiState._focusedElement ~= self._element then -- Blur the previously focused element's text editor if it has one if self._GuiState._focusedElement._textEditor then self._GuiState._focusedElement._textEditor:blur() end end self._focused = true if self._element then self._GuiState._focusedElement = self._element end self:_resetCursorBlink() if self.selectOnFocus then self:selectAll() else self:moveCursorToEnd() end if self.onFocus and self._element then self.onFocus(self._element) end self:_saveState() end ---Remove focus from this element function TextEditor:blur() self._focused = false if self._element and self._GuiState._focusedElement == self._element then self._GuiState._focusedElement = nil end if self.onBlur and self._element then self.onBlur(self._element) end self:_saveState() 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 text string function TextEditor:handleTextInput(text) if not self._focused then return end -- Trigger onTextInput callback if defined if self.onTextInput and self._element then local result = self.onTextInput(self._element, text) if result == false then return end end local oldText = self._textBuffer -- Delete selection if exists if self:hasSelection() then self:deleteSelection() end -- Insert text at cursor position self:insertText(text) -- Trigger onTextChange callback if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end self:_saveState() end ---Handle key press (special keys) ---@param key string -- Key name ---@param scancode string -- Scancode ---@param isrepeat boolean -- Whether this is a key repeat function TextEditor:handleKeyPress(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() 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(-1) end elseif key == "right" then if modifiers.super then self:moveCursorToEnd() 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(1) end elseif key == "home" then if not self.multiline then self:moveCursorToStart() else self:moveCursorToLineStart() end if not modifiers.shift then self:clearSelection() end elseif key == "end" then if not self.multiline then self:moveCursorToEnd() else self:moveCursorToLineEnd() 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(self._selectionAnchor, self._cursorPosition) elseif not modifiers.shift then self._selectionAnchor = nil end self:_resetCursorBlink() -- Handle backspace and delete elseif key == "backspace" then local oldText = self._textBuffer if self:hasSelection() then self:deleteSelection() elseif ctrl then if self._cursorPosition > 0 then self:deleteText(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(deleteStart, deleteEnd) self:_validateCursorPosition() end if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(true) elseif key == "delete" then local oldText = self._textBuffer if self:hasSelection() then self:deleteSelection() else local textLength = utf8.len(self._textBuffer or "") if self._cursorPosition < textLength then self:deleteText(self._cursorPosition, self._cursorPosition + 1) end end if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(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() end self:insertText("\n") if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end else if self.onEnter and self._element then self.onEnter(self._element) end end self:_resetCursorBlink(true) -- Handle Ctrl/Cmd+A (select all) elseif ctrl and key == "a" then self:selectAll() self:_resetCursorBlink() -- 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() -- 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() if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end end end self:_resetCursorBlink(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() end self:insertText(clipboardText) if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end end self:_resetCursorBlink(true) -- Handle Escape elseif key == "escape" then if self:hasSelection() then self:clearSelection() else self:blur() end self:_resetCursorBlink() end self:_saveState() end -- ==================== -- Mouse Input -- ==================== ---Convert mouse coordinates to cursor position in text ---@param mouseX number -- Mouse X coordinate (absolute) ---@param mouseY number -- Mouse Y coordinate (absolute) ---@return number -- Cursor position (character index) function TextEditor:mouseToTextPosition(mouseX, mouseY) if not self._element or not self._textBuffer then return 0 end local font = self:_getFont() if not font then return 0 end -- Get content area bounds local contentX = (self._element._absoluteX or self._element.x) + self._element.padding.left local contentY = (self._element._absoluteY or self._element.y) + self._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() -- 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 = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() if scaledContentPadding then local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._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(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 mouseX number ---@param mouseY number ---@param clickCount number -- 1=single, 2=double, 3=triple function TextEditor:handleTextClick(mouseX, mouseY, clickCount) if not self._focused then return end if clickCount == 1 then local pos = self:mouseToTextPosition(mouseX, mouseY) self:setCursorPosition(pos) self:clearSelection() self._mouseDownPosition = pos elseif clickCount == 2 then self:_selectWordAtPosition(self:mouseToTextPosition(mouseX, mouseY)) elseif clickCount >= 3 then self:selectAll() end self:_resetCursorBlink() end ---Handle mouse drag for text selection ---@param mouseX number ---@param mouseY number function TextEditor:handleTextDrag(mouseX, mouseY) if not self._focused or not self._mouseDownPosition then return end local currentPos = self:mouseToTextPosition(mouseX, mouseY) if currentPos ~= self._mouseDownPosition then self:setSelection(self._mouseDownPosition, currentPos) self._cursorPosition = currentPos self._textDragOccurred = true else self:clearSelection() end self:_resetCursorBlink() end ---Select word at given position ---@param position number function TextEditor:_selectWordAtPosition(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(startPos, endPos) self._cursorPosition = endPos end -- ==================== -- Update and Rendering -- ==================== ---Update cursor blink animation ---@param dt number -- Delta time function TextEditor:update(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 end ---Update element height based on text content (for autoGrow) function TextEditor:updateAutoGrowHeight() if not self.multiline or not self.autoGrow or not self._element then return end local font = self:_getFont() if not font then return end local text = self._textBuffer or "" local lineHeight = font:getHeight() -- Get text area width local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() if scaledContentPadding then local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._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(line, textAreaWidth) totalWrappedLines = totalWrappedLines + #wrappedSegments end end else totalWrappedLines = #lines end totalWrappedLines = math.max(1, totalWrappedLines) local newContentHeight = totalWrappedLines * lineHeight if self._element.height ~= newContentHeight then self._element.height = newContentHeight self._element._borderBoxHeight = self._element.height + self._element.padding.top + self._element.padding.bottom if self._element.parent and not self._element._explicitlyAbsolute then self._element.parent:layoutChildren() end end end -- ==================== -- Helper Methods -- ==================== ---Get font for text rendering ---@return love.Font? function TextEditor:_getFont() if not self._element then return nil end -- Delegate to Renderer return self._element._renderer:getFont(self._element) end ---Save state to StateManager (for immediate mode) function TextEditor:_saveState() if not self._element or not self._element._stateId or not self._GuiState._immediateMode then return end self._StateManager.updateState(self._element._stateId, { _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, }) end return TextEditor