multi-line selection fixed

This commit is contained in:
Michael Freno
2025-11-09 10:22:25 -05:00
parent 0a88c952bb
commit 3690202f48
2 changed files with 583 additions and 37 deletions

View File

@@ -2749,35 +2749,24 @@ function Element:draw(backdropCanvas)
local selectionColor = self.selectionColor or Color.new(0.3, 0.5, 0.8, 0.5) 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.opacity) local selectionWithOpacity = Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity)
-- Calculate selection bounds safely -- Get selection rectangles (handles multiline and wrapping)
local beforeSelection = "" local selectionRects = self:_getSelectionRects(selStart, selEnd)
local selectedText = ""
local startByte = utf8.offset(self.text, selStart + 1)
local endByte = utf8.offset(self.text, selEnd + 1)
if startByte and endByte then
beforeSelection = self.text:sub(1, startByte - 1)
selectedText = self.text:sub(startByte, endByte - 1)
end
local selX = (tx or contentX) + font:getWidth(beforeSelection)
local selWidth = font:getWidth(selectedText)
local selY = ty or contentY
local selHeight = textHeight
-- 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)
end end
-- Draw selection background -- Draw selection background rectangles
love.graphics.setColor(selectionWithOpacity:toRGBA()) love.graphics.setColor(selectionWithOpacity:toRGBA())
love.graphics.rectangle("fill", selX, selY, selWidth, selHeight) for _, rect in ipairs(selectionRects) do
local rectX = contentX + rect.x
-- Redraw selected text on top local rectY = contentY + rect.y
love.graphics.setColor(textColorWithOpacity:toRGBA()) if not self.multiline and self._textScrollX then
love.graphics.print(selectedText, selX, selY) rectX = rectX - self._textScrollX
end
love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height)
end
-- Reset scissor -- Reset scissor
if not self.multiline then if not self.multiline then
@@ -4769,6 +4758,158 @@ function Element:_getCursorScreenPosition()
return 0, #lines * lineHeight return 0, #lines * lineHeight
end 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 Element:_getSelectionRects(selStart, selEnd)
if not self.editable then
return {}
end
local font = self:_getFont()
if not font then
return {}
end
local text = self._textBuffer or ""
local rects = {}
-- For single-line text, calculate simple rectangle
if not self.multiline then
local startByte = utf8.offset(text, selStart + 1)
local endByte = utf8.offset(text, selEnd + 1)
if startByte and endByte then
local beforeSelection = text:sub(1, startByte - 1)
local selectedText = text: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.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
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
--- Update element height based on text content (for autoGrow multiline fields) --- Update element height based on text content (for autoGrow multiline fields)
function Element:_updateAutoGrowHeight() function Element:_updateAutoGrowHeight()
if not self.editable or not self.multiline or not self.autoGrow then if not self.editable or not self.multiline or not self.autoGrow then
@@ -4845,20 +4986,27 @@ function Element:_mouseToTextPosition(mouseX, mouseY)
local contentX = (self._absoluteX or self.x) + self.padding.left local contentX = (self._absoluteX or self.x) + self.padding.left
local contentY = (self._absoluteY or self.y) + self.padding.top local contentY = (self._absoluteY or self.y) + self.padding.top
-- Calculate relative X position within text area -- Calculate relative position within text area
local relativeX = mouseX - contentX local relativeX = mouseX - contentX
local relativeY = mouseY - contentY
-- Account for horizontal scroll offset in single-line inputs
if not self.multiline and self._textScrollX then
relativeX = relativeX + self._textScrollX
end
-- Get font for measuring text -- Get font for measuring text
local font = self:_getFont() local font = self:_getFont()
if not font then
return 0
end
-- Find the character position closest to the click
local text = self._textBuffer local text = self._textBuffer
local textLength = utf8.len(text) or 0 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 closestPos = 0
local closestDist = math.huge local closestDist = math.huge
@@ -4879,6 +5027,96 @@ function Element:_mouseToTextPosition(mouseX, mouseY)
end end
return closestPos 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.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
-- 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 -- +1 for newline character
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 end
--- Handle mouse click on text (set cursor position or start selection) --- Handle mouse click on text (set cursor position or start selection)

View File

@@ -1776,5 +1776,313 @@ function TestInputField:testTextScrollWithBackspace()
lu.assertTrue(element._textScrollX <= initialScroll) lu.assertTrue(element._textScrollX <= initialScroll)
end end
-- ====================
-- Multiline Text Selection Tests
-- ====================
function TestInputField:testMultilineMouseToTextPositionBasic()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "Line 1\nLine 2\nLine 3",
})
element:focus()
-- Get font to calculate positions
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Click at start (should be position 0)
local pos = element:_mouseToTextPosition(10, 10)
lu.assertEquals(pos, 0)
-- Click on second line start (should be after "Line 1\n" = position 7)
pos = element:_mouseToTextPosition(10, 10 + lineHeight)
lu.assertEquals(pos, 7)
-- Click on third line start (should be after "Line 1\nLine 2\n" = position 14)
pos = element:_mouseToTextPosition(10, 10 + lineHeight * 2)
lu.assertEquals(pos, 14)
end
function TestInputField:testMultilineMouseToTextPositionXCoordinate()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "ABC\nDEF\nGHI",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
local charWidth = font:getWidth("A")
-- Click in middle of first line (should be around position 1-2)
local pos = element:_mouseToTextPosition(10 + charWidth * 1.5, 10)
lu.assertTrue(pos >= 1 and pos <= 2)
-- Click at end of second line (should be around position 6-7)
-- Text is "ABC\nDEF\nGHI", so second line "DEF" ends at position 6 or 7 (newline)
pos = element:_mouseToTextPosition(10 + charWidth * 3, 10 + lineHeight)
lu.assertTrue(pos >= 6 and pos <= 7)
end
function TestInputField:testMultilineMouseDragSelection()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "Line 1\nLine 2\nLine 3",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Simulate mouse click on first line (sets _mouseDownPosition)
element:_handleTextClick(10, 10, 1)
lu.assertEquals(element._cursorPosition, 0)
lu.assertFalse(element:hasSelection())
-- Drag to second line
element:_handleTextDrag(50, 10 + lineHeight)
lu.assertTrue(element:hasSelection())
-- Selection should span from first line to second line
local startPos, endPos = element:getSelection()
lu.assertTrue(startPos == 0 or endPos == 0)
lu.assertTrue(startPos > 6 or endPos > 6) -- Past first newline
-- After drag, selection should be preserved
lu.assertTrue(element:hasSelection())
end
function TestInputField:testMultilineMouseDragAcrossThreeLines()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 150,
editable = true,
multiline = true,
text = "First\nSecond\nThird\nFourth",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Click on first line, then drag to third line
element:_handleTextClick(10, 10, 1)
element:_handleTextDrag(50, 10 + lineHeight * 2.5)
lu.assertTrue(element:hasSelection())
local startPos, endPos = element:getSelection()
-- Should select across multiple lines
local minPos = math.min(startPos, endPos)
local maxPos = math.max(startPos, endPos)
lu.assertEquals(minPos, 0) -- From start
lu.assertTrue(maxPos > 12) -- Past "First\nSecond\n"
-- Selection should persist after drag
lu.assertTrue(element:hasSelection())
end
function TestInputField:testMultilineClickOnDifferentLines()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "AAA\nBBB\nCCC",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Click on first line
element:_handleTextClick(10, 10, 1)
local pos1 = element._cursorPosition
lu.assertEquals(pos1, 0)
-- Click on second line
element:_handleTextClick(10, 10 + lineHeight, 1)
local pos2 = element._cursorPosition
lu.assertEquals(pos2, 4) -- After "AAA\n"
-- Click on third line
element:_handleTextClick(10, 10 + lineHeight * 2, 1)
local pos3 = element._cursorPosition
lu.assertEquals(pos3, 8) -- After "AAA\nBBB\n"
end
function TestInputField:testMultilineSelectionWithKeyboard()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "Line 1\nLine 2\nLine 3",
})
element:focus()
element:setCursorPosition(0)
-- Mock Shift key
local oldIsDown = _G.love.keyboard.isDown
_G.love.keyboard.isDown = function(...)
local keys = {...}
for _, key in ipairs(keys) do
if key == "lshift" or key == "rshift" then
return true
end
end
return false
end
-- Test Shift+Right selection (horizontal movement works)
element:keypressed("right", nil, false)
lu.assertTrue(element:hasSelection())
local startPos, endPos = element:getSelection()
lu.assertTrue(math.abs(endPos - startPos) > 0)
-- Reset mock
_G.love.keyboard.isDown = oldIsDown
end
function TestInputField:testMultilineMouseSelectionPreservedAfterRelease()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "First line\nSecond line\nThird line",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Create a selection by dragging
element:_handleTextClick(10, 10, 1)
element:_handleTextDrag(100, 10 + lineHeight * 1.5)
local startPos, endPos = element:getSelection()
local hadSelection = element:hasSelection()
lu.assertTrue(hadSelection)
-- Note: There's no mouse release handler that affects selection
-- The drag creates the selection and it persists
-- Selection should still exist
lu.assertTrue(element:hasSelection())
local startPos2, endPos2 = element:getSelection()
lu.assertEquals(startPos, startPos2)
lu.assertEquals(endPos, endPos2)
end
function TestInputField:testMultilineClickDoesNotPreserveSelection()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 100,
editable = true,
multiline = true,
text = "First line\nSecond line",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Create a selection
element:setSelection(0, 5)
lu.assertTrue(element:hasSelection())
-- Click somewhere else (should clear selection)
element:_handleTextClick(10, 10 + lineHeight, 1)
-- Selection should be cleared
lu.assertFalse(element:hasSelection())
end
function TestInputField:testMultilineEmptyLinesHandling()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 150,
editable = true,
multiline = true,
text = "Line 1\n\nLine 3",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Click on empty line (second line)
local pos = element:_mouseToTextPosition(10, 10 + lineHeight)
lu.assertEquals(pos, 7) -- After "Line 1\n"
-- Click on third line
pos = element:_mouseToTextPosition(10, 10 + lineHeight * 2)
lu.assertEquals(pos, 8) -- After "Line 1\n\n"
end
function TestInputField:testMultilineYCoordinateBeyondText()
local element = FlexLove.Element.new({
x = 10,
y = 10,
width = 300,
height = 200,
editable = true,
multiline = true,
text = "Line 1\nLine 2",
})
element:focus()
local font = element:_getFont()
local lineHeight = font:getHeight()
-- Click way below the text (should clamp to last line)
local pos = element:_mouseToTextPosition(10, 10 + lineHeight * 10)
local textLen = utf8.len(element.text)
-- Should be at or near end of text
lu.assertTrue(pos >= textLen - 6) -- Within last line
end
-- Run tests -- Run tests
lu.LuaUnit.run() lu.LuaUnit.run()