1559 lines
44 KiB
Lua
1559 lines
44 KiB
Lua
-- ====================
|
|
-- 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)
|
|
|
|
-- Setup module path for relative requires
|
|
local modulePath = (...):match("(.-)[^%.]+$")
|
|
local function req(name)
|
|
return require(modulePath .. name)
|
|
end
|
|
|
|
-- Module dependencies
|
|
local GuiState = req("GuiState")
|
|
local StateManager = req("StateManager")
|
|
local Color = req("Color")
|
|
local utils = req("utils")
|
|
|
|
-- Extract utilities
|
|
local FONT_CACHE = utils.FONT_CACHE
|
|
local getModifiers = utils.getModifiers
|
|
|
|
-- 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
|
|
---@return table TextEditor instance
|
|
function TextEditor.new(config)
|
|
local self = setmetatable({}, TextEditor)
|
|
|
|
-- 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 GuiState._immediateMode then
|
|
local state = StateManager.getState(element._stateId)
|
|
if state then
|
|
if state._focused then
|
|
self._focused = true
|
|
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 GuiState._focusedElement and GuiState._focusedElement ~= self._element then
|
|
-- Blur the previously focused element's text editor if it has one
|
|
if GuiState._focusedElement._textEditor then
|
|
GuiState._focusedElement._textEditor:blur()
|
|
end
|
|
end
|
|
|
|
self._focused = true
|
|
if self._element then
|
|
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 GuiState._focusedElement == self._element then
|
|
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 = 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 GuiState._immediateMode then
|
|
return
|
|
end
|
|
|
|
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
|