bug in backspace - but multiline works

This commit is contained in:
Michael Freno
2025-11-07 17:44:18 -05:00
parent c571e27d2b
commit 3f17ed7d29
2 changed files with 320 additions and 41 deletions

View File

@@ -4,7 +4,7 @@ Simple input field demo - multiple fields to test all features
Uses retained mode - elements are created once and reused Uses retained mode - elements are created once and reused
--]] --]]
local FlexLove = require("FlexLove") local FlexLove = require("libs.FlexLove")
local Element = FlexLove.Element local Element = FlexLove.Element
local Color = FlexLove.Color local Color = FlexLove.Color
@@ -20,11 +20,20 @@ local function initialize()
return return
end end
initialized = true initialized = true
elements.container = Element.new({
x = 0,
y = 0,
padding = { horizontal = "5%", vertical = "5%" },
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
flexDirection = "vertical",
positioning = "flex",
gap = 10,
})
-- Title -- Title
elements.title = Element.new({ elements.title = Element.new({
x = 50, parent = elements.container,
y = 50,
width = 700, width = 700,
height = 40, height = 40,
text = "FlexLove Input Field Demo", text = "FlexLove Input Field Demo",
@@ -35,8 +44,7 @@ local function initialize()
-- Input field 1 - Empty with placeholder -- Input field 1 - Empty with placeholder
elements.inputField1 = Element.new({ elements.inputField1 = Element.new({
x = 50, parent = elements.container,
y = 120,
width = 600, width = 600,
height = 50, height = 50,
editable = true, editable = true,
@@ -48,17 +56,17 @@ local function initialize()
padding = { horizontal = 15, vertical = 12 }, padding = { horizontal = 15, vertical = 12 },
placeholder = "Type here... (empty field with placeholder)", placeholder = "Type here... (empty field with placeholder)",
selectOnFocus = false, selectOnFocus = false,
multiline = true,
autoGrow = true,
z = 1000, z = 1000,
}) onTextChange = function(element, newText)
elements.inputField1.onTextChange = function(element, newText)
print("Field 1 changed:", newText) print("Field 1 changed:", newText)
end end,
})
-- Input field 2 - Pre-filled with selectOnFocus -- Input field 2 - Pre-filled with selectOnFocus
elements.inputField2 = Element.new({ elements.inputField2 = Element.new({
x = 50, parent = elements.container,
y = 200,
width = 600, width = 600,
height = 50, height = 50,
editable = true, editable = true,
@@ -71,16 +79,13 @@ local function initialize()
placeholder = "This shouldn't show", placeholder = "This shouldn't show",
selectOnFocus = true, selectOnFocus = true,
z = 1000, z = 1000,
onTextChange = function(_, newText)
print("Field 2 changed:", newText)
end,
}) })
elements.inputField2.onTextChange = function(element, newText)
print("Field 2 changed:", newText)
end
-- Input field 3 - With max length
elements.inputField3 = Element.new({ elements.inputField3 = Element.new({
x = 50, parent = elements.container,
y = 280,
width = 600, width = 600,
height = 50, height = 50,
editable = true, editable = true,
@@ -94,16 +99,13 @@ local function initialize()
maxLength = 20, maxLength = 20,
selectOnFocus = false, selectOnFocus = false,
z = 1000, z = 1000,
onTextChange = function(element, newText)
print("Field 3 changed:", newText)
end,
}) })
elements.inputField3.onTextChange = function(element, newText)
print("Field 3 changed:", newText)
end
-- Instructions
elements.instructions = Element.new({ elements.instructions = Element.new({
x = 50, parent = elements.container,
y = 360,
width = 700, width = 700,
height = 200, height = 200,
text = "Instructions:\n• Click on a field to focus it\n• Type to enter text\n• Field 1: Empty with placeholder\n• Field 2: Pre-filled, selects all on focus\n• Field 3: Max 20 characters\n• Press ESC to unfocus\n• Use arrow keys to move cursor", text = "Instructions:\n• Click on a field to focus it\n• Type to enter text\n• Field 1: Empty with placeholder\n• Field 2: Pre-filled, selects all on focus\n• Field 3: Max 20 characters\n• Press ESC to unfocus\n• Use arrow keys to move cursor",

View File

@@ -309,7 +309,12 @@ function Element.new(props)
if self.scrollable == nil then if self.scrollable == nil then
self.scrollable = self.multiline self.scrollable = self.multiline
end end
self.autoGrow = props.autoGrow or false -- autoGrow defaults to true for multiline, false for single-line
if props.autoGrow ~= nil then
self.autoGrow = props.autoGrow
else
self.autoGrow = self.multiline
end
self.selectOnFocus = props.selectOnFocus or false self.selectOnFocus = props.selectOnFocus or false
-- Cursor and selection properties -- Cursor and selection properties
@@ -2581,11 +2586,18 @@ function Element:draw(backdropCanvas)
-- Draw element text if present -- Draw element text if present
-- For editable elements, also handle placeholder -- For editable elements, also handle placeholder
local displayText = self.text -- Update text layout if dirty (for multiline auto-grow)
if self.editable then
self:_updateTextIfDirty()
self:_updateAutoGrowHeight()
end
-- For editable elements, use _textBuffer; for non-editable, use text
local displayText = self.editable and self._textBuffer or self.text
local isPlaceholder = false local isPlaceholder = false
-- Show placeholder if editable, empty, and not focused -- Show placeholder if editable, empty, and not focused
if self.editable and (not self.text or self.text == "") and self.placeholder and not self._focused then if self.editable and (not displayText or displayText == "") and self.placeholder and not self._focused then
displayText = self.placeholder displayText = self.placeholder
isPlaceholder = true isPlaceholder = true
end end
@@ -2707,18 +2719,17 @@ function Element:draw(backdropCanvas)
local cursorWithOpacity = Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self.opacity) local cursorWithOpacity = Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self.opacity)
love.graphics.setColor(cursorWithOpacity:toRGBA()) love.graphics.setColor(cursorWithOpacity:toRGBA())
-- Calculate cursor position -- Calculate cursor position using new method that handles multiline
local cursorText = "" local cursorRelX, cursorRelY = self:_getCursorScreenPosition()
if self.text and self.text ~= "" and self._cursorPosition > 0 then local cursorX = contentX + cursorRelX
local byteOffset = utf8.offset(self.text, self._cursorPosition + 1) local cursorY = contentY + cursorRelY
if byteOffset then
cursorText = self.text:sub(1, byteOffset - 1)
end
end
local cursorX = (tx or contentX) + font:getWidth(cursorText)
local cursorY = ty or contentY
local cursorHeight = textHeight 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 -- Apply scissor for single-line editable inputs
if not self.multiline then if not self.multiline then
love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight)
@@ -4314,6 +4325,8 @@ function Element:setText(text)
self._textBuffer = text or "" self._textBuffer = text or ""
self.text = self._textBuffer -- Sync display text self.text = self._textBuffer -- Sync display text
self:_markTextDirty() self:_markTextDirty()
self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping
self:_updateAutoGrowHeight() -- Then update height based on new content
self:_validateCursorPosition() self:_validateCursorPosition()
end end
@@ -4352,7 +4365,12 @@ function Element:insertText(text, position)
-- Update cursor position -- Update cursor position
self._cursorPosition = position + utf8.len(text) self._cursorPosition = position + utf8.len(text)
print(string.format("[InsertText] Text: '%s', multiline: %s, autoGrow: %s",
self._textBuffer:gsub("\n", "\\n"), tostring(self.multiline), tostring(self.autoGrow)))
self:_markTextDirty() self:_markTextDirty()
self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping
self:_updateAutoGrowHeight() -- Then update height based on new content
self:_validateCursorPosition() self:_validateCursorPosition()
end end
@@ -4386,6 +4404,8 @@ function Element:deleteText(startPos, endPos)
self.text = self._textBuffer -- Sync display text self.text = self._textBuffer -- Sync display text
self:_markTextDirty() self:_markTextDirty()
self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping
self:_updateAutoGrowHeight() -- Then update height based on new content
end end
--- Replace text in range --- Replace text in range
@@ -4488,6 +4508,21 @@ function Element:_wrapLine(line, maxWidth)
local currentLine = "" local currentLine = ""
local startIdx = 0 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
print(string.format("[WrapLine] line length: %d, maxWidth: %.1f, textWrap: %s",
utf8.len(line) or 0, maxWidth, tostring(self.textWrap)))
if self.textWrap == "word" then if self.textWrap == "word" then
-- Word wrapping -- Word wrapping
local words = {} local words = {}
@@ -4501,13 +4536,68 @@ function Element:_wrapLine(line, maxWidth)
if width > maxWidth and currentLine ~= "" then if width > maxWidth and currentLine ~= "" then
-- Current line is full, start new line -- Current line is full, start new line
local currentLineLen = utf8.len(currentLine)
table.insert(wrappedParts, { table.insert(wrappedParts, {
text = currentLine, text = currentLine,
startIdx = startIdx, startIdx = startIdx,
endIdx = startIdx + utf8.len(currentLine), endIdx = startIdx + currentLineLen,
}) })
startIdx = startIdx + currentLineLen + 1 -- +1 for the space
currentLine = word currentLine = word
startIdx = startIdx + utf8.len(currentLine) + 1
-- Check if the word itself is too long - if so, break it with character wrapping
if font:getWidth(word) > maxWidth then
local wordLen = utf8.len(word)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(word, 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(word)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(word, 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
else else
currentLine = testLine currentLine = testLine
end end
@@ -4516,7 +4606,7 @@ function Element:_wrapLine(line, maxWidth)
-- Character wrapping -- Character wrapping
local lineLength = utf8.len(line) local lineLength = utf8.len(line)
for i = 1, lineLength do for i = 1, lineLength do
local char = utf8.sub(line, i, i) local char = getUtf8Char(line, i)
local testLine = currentLine .. char local testLine = currentLine .. char
local width = font:getWidth(testLine) local width = font:getWidth(testLine)
@@ -4552,6 +4642,11 @@ function Element:_wrapLine(line, maxWidth)
}) })
end end
if #wrappedParts > 1 then
print(string.format("[WrapLine] Returning %d segments for line length %d",
#wrappedParts, utf8.len(line) or 0))
end
return wrappedParts return wrappedParts
end end
@@ -4573,6 +4668,188 @@ function Element:_getFont()
return FONT_CACHE.getFont(self.textSize, fontPath) return FONT_CACHE.getFont(self.textSize, fontPath)
end end
--- Get cursor screen position for rendering (handles multiline text)
---@return number, number -- Cursor X and Y position relative to content area
function Element:_getCursorScreenPosition()
if not self.editable 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
-- For single-line text, calculate simple X position
if not self.multiline then
local cursorText = ""
if text ~= "" and cursorPos > 0 then
local byteOffset = utf8.offset(text, cursorPos + 1)
if byteOffset then
cursorText = text: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
-- Update text wrapping if dirty
self:_updateTextIfDirty()
-- Get text area width for wrapping
local textAreaWidth = self.width
local scaledContentPadding = self:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.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
-- Extract substring by character positions using byte offsets
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)
-- Add line offset for wrapped segments
cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight
print(string.format("[CursorCalc] Line %d, Segment %d, posInLine: %d, startIdx: %d, endIdx: %d, cursorY: %.1f",
lineNum, segmentIdx, posInLine, segment.startIdx, segment.endIdx, cursorY))
return cursorX, cursorY
end
end
else
-- No wrapping, simple calculation
local lineText = ""
if posInLine > 0 then
-- Extract substring by character positions using byte offsets
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
-- Move to next line (add 1 for the newline character)
charCount = charCount + lineLength + 1
end
-- Cursor is at the very end
return 0, (#lines) * lineHeight
end
--- Update element height based on text content (for autoGrow multiline fields)
function Element:_updateAutoGrowHeight()
if not self.editable 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.width
local scaledContentPadding = self:getScaledContentPadding()
if scaledContentPadding then
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.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
-- Ensure at least one line
totalWrappedLines = math.max(1, totalWrappedLines)
-- Calculate new content height
local newContentHeight = totalWrappedLines * lineHeight
-- Update height if it changed
if self.height ~= newContentHeight then
print(string.format("[AutoGrow] Height changing from %s to %s (lines: %d)",
tostring(self.height), tostring(newContentHeight), totalWrappedLines))
self.height = newContentHeight
self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom
-- Re-layout parent if this element participates in parent layout
if self.parent and not self._explicitlyAbsolute then
print("[AutoGrow] Re-layouting parent")
self.parent:layoutChildren()
end
end
end
-- ==================== -- ====================
-- Input Handling - Mouse Selection -- Input Handling - Mouse Selection
-- ==================== -- ====================