Files
FlexLove/modules/TextEditor.lua
2025-11-12 17:19:46 -05:00

1926 lines
56 KiB
Lua

-- ====================
-- TextEditor Module
-- ====================
-- Extracted text editing functionality from Element.lua
-- Handles all text input, cursor management, selection, and text rendering for editable elements
-- 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
-- Reference to Gui (via GuiState)
local Gui = GuiState
-- UTF-8 support (available in LÖVE/Lua 5.3+)
local utf8 = utf8 or require("utf8")
---@class TextEditor
---@field editable boolean
---@field multiline boolean
---@field passwordMode boolean
---@field textWrap boolean|"word"|"char"
---@field maxLines number?
---@field maxLength number?
---@field placeholder string?
---@field inputType "text"|"number"|"email"|"url"
---@field textOverflow "clip"|"ellipsis"|"scroll"
---@field scrollable boolean
---@field autoGrow boolean
---@field selectOnFocus boolean
---@field cursorColor Color?
---@field selectionColor Color?
---@field cursorBlinkRate number
---@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 _textBuffer string
---@field _lines table?
---@field _wrappedLines table?
---@field _textDirty boolean
---@field _textScrollX number
---@field _mouseDownPosition number?
---@field _element Element?
local TextEditor = {}
TextEditor.__index = TextEditor
--- Create a new TextEditor instance
---@param config table Configuration options
---@return TextEditor
function TextEditor.new(config)
local self = setmetatable({}, TextEditor)
-- 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 cursor and selection state
self._cursorPosition = 0
self._cursorLine = 1
self._cursorColumn = 0
self._cursorBlinkTimer = 0
self._cursorVisible = true
self._cursorBlinkPaused = false
self._cursorBlinkPauseTimer = 0
-- Selection state
self._selectionStart = nil
self._selectionEnd = nil
self._selectionAnchor = nil
-- Focus state
self._focused = false
-- Text buffer state
self._textBuffer = config.text or ""
self._lines = nil
self._wrappedLines = nil
self._textDirty = true
-- Scroll state
self._textScrollX = 0
-- Mouse tracking
self._mouseDownPosition = nil
-- Element reference (set via initialize)
self._element = nil
return self
end
--- Initialize with parent element reference
---@param element Element The parent element
function TextEditor:initialize(element)
self._element = element
-- Restore state from StateManager in immediate mode
if Gui._immediateMode and element._stateId then
local state = StateManager.getState(element._stateId)
if state then
-- Restore focus state
if state._focused then
self._focused = true
Gui._focusedElement = element
end
-- Restore text buffer
if state._textBuffer and state._textBuffer ~= "" then
self._textBuffer = state._textBuffer
end
-- Restore cursor position
if state._cursorPosition then
self._cursorPosition = state._cursorPosition
end
-- Restore selection
if state._selectionStart then
self._selectionStart = state._selectionStart
end
if state._selectionEnd then
self._selectionEnd = state._selectionEnd
end
-- Restore cursor blink state
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
-- ====================
-- 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? 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
-- Update scroll to keep cursor visible
self:_updateTextScroll()
end
--- Update text scroll offset to keep cursor visible
function TextEditor:_updateTextScroll()
if not self._element or self.multiline then
return
end
-- Get font for measuring text
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 (accounting for padding)
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
-- Cursor is to the left of visible area - scroll left
self._textScrollX = cursorX
elseif cursorX - self._textScrollX > visibleWidth then
-- Cursor is to the right of visible area - scroll right
self._textScrollX = cursorX - visibleWidth
end
-- Ensure we don't scroll past the beginning
self._textScrollX = math.max(0, self._textScrollX)
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 for string.sub
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 is nil, it means we want to the end of the string
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()
-- Save state to StateManager in immediate mode
self:_saveEditableState()
return true
end
-- ====================
-- Focus Management
-- ====================
--- Focus this element for keyboard input
function TextEditor:focus()
if not self._element then
return
end
if Gui._focusedElement and Gui._focusedElement ~= self._element then
-- Blur the previously focused element
if Gui._focusedElement.editable and Gui._focusedElement._textEditor then
Gui._focusedElement._textEditor:blur()
else
Gui._focusedElement:blur()
end
end
-- Set focus state
self._focused = true
Gui._focusedElement = self._element
self:_resetCursorBlink()
if self.selectOnFocus then
self:selectAll()
else
self:moveCursorToEnd()
end
-- Trigger onFocus callback if defined
if self._element.onFocus then
self._element.onFocus(self._element)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Remove focus from this element
function TextEditor:blur()
if not self._element then
return
end
self._focused = false
-- Clear global focused element if it's this element
if Gui._focusedElement == self._element then
Gui._focusedElement = nil
end
-- Trigger onBlur callback if defined
if self._element.onBlur then
self._element.onBlur(self._element)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Check if this element is focused
---@return boolean
function TextEditor:isFocused()
return self._focused == true
end
--- Save editable element state to StateManager (for immediate mode)
function TextEditor:_saveEditableState()
if not self._element or not self._element._stateId or not Gui._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
-- ====================
-- 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)
if not self._element then
self._textBuffer = text or ""
return
end
self._textBuffer = text or ""
self._element.text = self._textBuffer -- Sync display text
self:_markTextDirty()
self:_updateTextIfDirty()
self:_updateAutoGrowHeight()
self:_validateCursorPosition()
-- Save state to StateManager in immediate mode
self:_saveEditableState()
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
if self._element then
self._element.text = self._textBuffer
end
self._cursorPosition = position + utf8.len(text)
self:_markTextDirty()
self:_updateTextIfDirty()
self:_updateAutoGrowHeight()
self:_validateCursorPosition()
-- Reset cursor blink to show cursor and pause blinking while typing
self:_resetCursorBlink(true)
-- Save state to StateManager in immediate mode
self:_saveEditableState()
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
if self._element then
self._element.text = self._textBuffer
end
self:_markTextDirty()
self:_updateTextIfDirty()
self:_updateAutoGrowHeight()
-- Reset cursor blink to show cursor and pause blinking while deleting
self:_resetCursorBlink(true)
-- Save state to StateManager in immediate mode
self:_saveEditableState()
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
-- ====================
-- Text Wrapping and Line Splitting
-- ====================
--- 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._element or not self.textWrap 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
local font = self:_getFont()
local wrappedParts = {}
local currentLine = ""
local startIdx = 0
-- Helper function to extract a UTF-8 character by character index
local function getUtf8Char(str, charIndex)
local byteStart = utf8.offset(str, charIndex)
if not byteStart then
return ""
end
local byteEnd = utf8.offset(str, charIndex + 1)
if byteEnd then
return str:sub(byteStart, byteEnd - 1)
else
return str:sub(byteStart)
end
end
if self.textWrap == "word" then
-- Tokenize into words and whitespace, preserving exact spacing
local tokens = {}
local pos = 1
local lineLen = utf8.len(line)
while pos <= lineLen do
-- Check if current position is whitespace
local char = getUtf8Char(line, pos)
if char:match("%s") then
-- Collect whitespace sequence
local wsStart = pos
while pos <= lineLen and getUtf8Char(line, pos):match("%s") do
pos = pos + 1
end
table.insert(tokens, {
type = "space",
text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line),
startPos = wsStart - 1,
length = pos - wsStart,
})
else
-- Collect word (non-whitespace sequence)
local wordStart = pos
while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do
pos = pos + 1
end
table.insert(tokens, {
type = "word",
text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line),
startPos = wordStart - 1,
length = pos - wordStart,
})
end
end
-- Process tokens and wrap
local charPos = 0
for i, token in ipairs(tokens) do
if token.type == "word" then
local testLine = currentLine .. token.text
local width = font:getWidth(testLine)
if width > maxWidth and currentLine ~= "" then
-- Current line is full, wrap before this word
local currentLineLen = utf8.len(currentLine)
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + currentLineLen,
})
startIdx = charPos
currentLine = token.text
charPos = charPos + token.length
-- Check if the word itself is too long - if so, break it with character wrapping
if font:getWidth(token.text) > maxWidth then
local wordLen = utf8.len(token.text)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(token.text, j)
local testCharLine = charLine .. char
local charWidth = font:getWidth(testCharLine)
if charWidth > maxWidth and charLine ~= "" then
table.insert(wrappedParts, {
text = charLine,
startIdx = charStartIdx,
endIdx = charStartIdx + utf8.len(charLine),
})
charStartIdx = charStartIdx + utf8.len(charLine)
charLine = char
else
charLine = testCharLine
end
end
currentLine = charLine
startIdx = charStartIdx
end
elseif width > maxWidth and currentLine == "" then
-- Word is too long to fit on a line by itself - use character wrapping
local wordLen = utf8.len(token.text)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(token.text, j)
local testCharLine = charLine .. char
local charWidth = font:getWidth(testCharLine)
if charWidth > maxWidth and charLine ~= "" then
table.insert(wrappedParts, {
text = charLine,
startIdx = charStartIdx,
endIdx = charStartIdx + utf8.len(charLine),
})
charStartIdx = charStartIdx + utf8.len(charLine)
charLine = char
else
charLine = testCharLine
end
end
currentLine = charLine
startIdx = charStartIdx
charPos = charPos + token.length
else
currentLine = testLine
charPos = charPos + token.length
end
else
-- It's whitespace - add to current line
currentLine = currentLine .. token.text
charPos = charPos + token.length
end
end
else
-- Character wrapping
local lineLength = utf8.len(line)
for i = 1, lineLength do
local char = getUtf8Char(line, i)
local testLine = currentLine .. char
local width = font:getWidth(testLine)
if width > maxWidth and currentLine ~= "" then
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + utf8.len(currentLine),
})
currentLine = char
startIdx = i - 1
else
currentLine = testLine
end
end
end
-- Add remaining text
if currentLine ~= "" then
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + utf8.len(currentLine),
})
end
-- Ensure at least one part
if #wrappedParts == 0 then
table.insert(wrappedParts, {
text = "",
startIdx = 0,
endIdx = 0,
})
end
return wrappedParts
end
--- Get font for text rendering
---@return love.Font
function TextEditor:_getFont()
if not self._element then
return love.graphics.getFont()
end
return self._element:_getFont()
end
-- ====================
-- Cursor and Selection Screen Position
-- ====================
--- Get cursor screen position for rendering (handles multiline text)
---@return number, number Cursor X and Y position relative to content area
function TextEditor:_getCursorScreenPosition()
if not self._element then
return 0, 0
end
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()
-- 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 (before the newline)
if cursorPos <= charCount + lineLength then
-- Cursor is on this line
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
-- Check if cursor is within this segment's character range
if posInLine >= segment.startIdx and posInLine <= segment.endIdx then
-- Cursor is in this segment
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
--- Get selection rectangles for rendering (handles multiline and wrapped text)
---@param selStart number Selection start position (character index)
---@param selEnd number Selection end position (character index)
---@return table Array of rectangles {x, y, width, height} relative to content area
function TextEditor:_getSelectionRects(selStart, selEnd)
if not self._element then
return {}
end
local font = self:_getFont()
if not font then
return {}
end
local text = self._textBuffer or ""
local rects = {}
-- Apply password masking for selection rectangle calculation
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, we need to 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 first
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
-- Check if selection intersects with this line
local lineStartChar = charCount
local lineEndChar = charCount + lineLength
if selEnd > lineStartChar and selStart <= lineEndChar then
-- Selection intersects with this line
local selStartInLine = math.max(0, selStart - charCount)
local selEndInLine = math.min(lineLength, selEnd - charCount)
-- If text wrapping is enabled, handle wrapped segments
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(line, textAreaWidth)
for segmentIdx, segment in ipairs(wrappedSegments) do
-- Check if selection intersects with this segment
if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then
-- Selection intersects with this segment
local segSelStart = math.max(segment.startIdx, selStartInLine)
local segSelEnd = math.min(segment.endIdx, selEndInLine)
-- Calculate X position and width
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, simple calculation
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 we still need to 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
-- ====================
-- Auto-Grow Height
-- ====================
--- Update element height based on text content (for autoGrow multiline fields)
function TextEditor:_updateAutoGrowHeight()
if not self._element or not self.multiline or not self.autoGrow 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 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
-- 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
-- ====================
-- Mouse Selection
-- ====================
--- 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
-- 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 within text area
local relativeX = mouseX - contentX
local relativeY = mouseY - contentY
-- Get font for measuring text
local font = self:_getFont()
if not font then
return 0
end
local text = self._textBuffer
local textLength = utf8.len(text) or 0
-- === SINGLE-LINE TEXT HANDLING ===
if not self.multiline then
-- Account for horizontal scroll offset in single-line inputs
if self._textScrollX then
relativeX = relativeX + self._textScrollX
end
-- Find the character position closest to the click
local closestPos = 0
local closestDist = math.huge
-- Check each position in the text
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 TEXT HANDLING ===
-- Update text wrapping if dirty
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 for wrapping calculations
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 the click is on based on Y coordinate
local clickedLineNum = math.floor(relativeY / lineHeight) + 1
clickedLineNum = math.max(1, math.min(clickedLineNum, #lines))
-- Calculate character offset for lines before the clicked line
local charOffset = 0
for i = 1, clickedLineNum - 1 do
local lineLen = utf8.len(lines[i]) or 0
charOffset = charOffset + lineLen + 1
end
-- Get the clicked line
local clickedLine = lines[clickedLineNum]
local lineLen = utf8.len(clickedLine) or 0
-- If text wrapping is enabled, handle wrapped segments
if self.textWrap and textAreaWidth > 0 then
local wrappedSegments = self:_wrapLine(clickedLine, textAreaWidth)
-- Determine which wrapped segment was clicked
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]
-- Find closest position within the segment
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 - find closest position in the clicked line
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 (set cursor position or start selection)
---@param mouseX number Mouse X coordinate
---@param mouseY number Mouse Y coordinate
---@param clickCount number Number of clicks (1=single, 2=double, 3=triple)
function TextEditor:handleTextClick(mouseX, mouseY, clickCount)
if not self._focused then
return
end
if clickCount == 1 then
-- Single click: Set cursor position
local pos = self:_mouseToTextPosition(mouseX, mouseY)
self:setCursorPosition(pos)
self:clearSelection()
-- Store position for potential drag selection
self._mouseDownPosition = pos
elseif clickCount == 2 then
-- Double click: Select word
self:_selectWordAtPosition(self:_mouseToTextPosition(mouseX, mouseY))
elseif clickCount >= 3 then
-- Triple click: Select all
self:selectAll()
end
self:_resetCursorBlink()
end
--- Handle mouse drag for text selection
---@param mouseX number Mouse X coordinate
---@param mouseY number Mouse Y coordinate
function TextEditor:handleTextDrag(mouseX, mouseY)
if not self._focused or not self._mouseDownPosition then
return
end
local currentPos = self:_mouseToTextPosition(mouseX, mouseY)
-- Create selection from mouse down position to current position
if currentPos ~= self._mouseDownPosition then
self:setSelection(self._mouseDownPosition, currentPos)
self._cursorPosition = currentPos
else
self:clearSelection()
end
self:_resetCursorBlink()
end
--- Select word at given position
---@param position number Character position
function TextEditor:_selectWordAtPosition(position)
if not self._textBuffer then
return
end
local text = self._textBuffer
local textLength = utf8.len(text) or 0
if position < 0 or position > textLength then
return
end
-- Find word boundaries
local wordStart = position
local wordEnd = position
-- Find start of word (move left while alphanumeric)
while wordStart > 0 do
local offset = utf8.offset(text, wordStart)
local char = offset and text:sub(offset, utf8.offset(text, wordStart + 1) - 1) or ""
if char:match("[%w]") then
wordStart = wordStart - 1
else
break
end
end
-- Find end of word (move right while alphanumeric)
while wordEnd < textLength do
local offset = utf8.offset(text, wordEnd + 1)
local char = offset and text:sub(offset, utf8.offset(text, wordEnd + 2) - 1) or ""
if char:match("[%w]") then
wordEnd = wordEnd + 1
else
break
end
end
-- Select the word
if wordEnd > wordStart then
self:setSelection(wordStart, wordEnd)
self._cursorPosition = wordEnd
end
end
--- Clear mouse down position (called on mouse release)
function TextEditor:clearMouseDownPosition()
self._mouseDownPosition = nil
end
-- ====================
-- Keyboard Input
-- ====================
--- Handle text input (character input)
---@param text string Character(s) to insert
function TextEditor:handleInput(text)
if not self._focused or not self._element then
return
end
-- Trigger onTextInput callback if defined
if self._element.onTextInput then
local result = self._element.onTextInput(self._element, text)
if result == false then
return
end
end
-- Capture old text for callback
local oldText = self._textBuffer
-- Delete selection if exists
local hadSelection = self:hasSelection()
if hadSelection then
self:deleteSelection()
end
-- Insert text at cursor position
self:insertText(text)
-- Trigger onTextChange callback if text changed
if self._element.onTextChange and self._textBuffer ~= oldText then
self._element.onTextChange(self._element, self._textBuffer, oldText)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
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 or not self._element 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
-- Set selection anchor if Shift is pressed and no anchor exists
if modifiers.shift and not self._selectionAnchor then
self._selectionAnchor = self._cursorPosition
end
-- Store old cursor position
local oldCursorPos = self._cursorPosition
-- Move cursor based on key
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" then
-- TODO: Implement up/down for multi-line
if not modifiers.shift then
self:clearSelection()
end
elseif key == "down" then
-- TODO: Implement up/down for multi-line
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
-- Ctrl/Cmd+Backspace: Delete all text from start to cursor
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._element.onTextChange and self._textBuffer ~= oldText then
self._element.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._element.onTextChange and self._textBuffer ~= oldText then
self._element.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._element.onTextChange and self._textBuffer ~= oldText then
self._element.onTextChange(self._element, self._textBuffer, oldText)
end
else
if self._element.onEnter then
self._element.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._element.onTextChange and self._textBuffer ~= oldText then
self._element.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._element.onTextChange and self._textBuffer ~= oldText then
self._element.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
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
-- ====================
-- Update
-- ====================
--- Update cursor blink timer
---@param dt number Delta time
function TextEditor:update(dt)
if not self._focused then
return
end
-- If blink is paused, increment pause timer
if self._cursorBlinkPaused then
self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt
-- Unpause after 0.5 seconds of no typing
if self._cursorBlinkPauseTimer >= 0.5 then
self._cursorBlinkPaused = false
self._cursorBlinkPauseTimer = 0
end
else
-- Normal blinking
self._cursorBlinkTimer = self._cursorBlinkTimer + dt
if self._cursorBlinkTimer >= self.cursorBlinkRate then
self._cursorBlinkTimer = 0
self._cursorVisible = not self._cursorVisible
end
end
end
-- ====================
-- Draw
-- ====================
--- Draw text, cursor, and selection
---@param x number Content area X position
---@param y number Content area Y position
---@param width number Content area width
---@param height number Content area height
function TextEditor:draw(x, y, width, height)
if not self._element then
return
end
local font = self:_getFont()
if not font then
return
end
local textHeight = font:getHeight()
-- Draw selection highlight
if self._focused and self:hasSelection() and self._textBuffer and self._textBuffer ~= "" then
local selStart, selEnd = self:getSelection()
local selectionColor = self.selectionColor or Color.new(0.3, 0.5, 0.8, 0.5)
local selectionWithOpacity = Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self._element.opacity)
local selectionRects = self:_getSelectionRects(selStart, selEnd)
-- Apply scissor for single-line editable inputs
if not self.multiline then
love.graphics.setScissor(x, y, width, height)
end
love.graphics.setColor(selectionWithOpacity:toRGBA())
for _, rect in ipairs(selectionRects) do
local rectX = x + rect.x
local rectY = y + rect.y
if not self.multiline and self._textScrollX then
rectX = rectX - self._textScrollX
end
love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height)
end
if not self.multiline then
love.graphics.setScissor()
end
end
-- Draw cursor
if self._focused and self._cursorVisible then
local cursorColor = self.cursorColor or self._element.textColor
local cursorWithOpacity = Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self._element.opacity)
love.graphics.setColor(cursorWithOpacity:toRGBA())
local cursorRelX, cursorRelY = self:_getCursorScreenPosition()
local cursorX = x + cursorRelX
local cursorY = y + cursorRelY
local cursorHeight = textHeight
-- Apply scroll offset for single-line inputs
if not self.multiline and self._textScrollX then
cursorX = cursorX - self._textScrollX
end
-- Apply scissor for single-line editable inputs
if not self.multiline then
love.graphics.setScissor(x, y, width, height)
end
love.graphics.rectangle("fill", cursorX, cursorY, 2, cursorHeight)
if not self.multiline then
love.graphics.setScissor()
end
end
end
return TextEditor