diff --git a/modules/Element.lua b/modules/Element.lua index bc73424..670b07a 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -2749,35 +2749,24 @@ function Element:draw(backdropCanvas) 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) - -- Calculate selection bounds safely - local beforeSelection = "" - 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 + -- Get selection rectangles (handles multiline and wrapping) + local selectionRects = self:_getSelectionRects(selStart, selEnd) -- Apply scissor for single-line editable inputs if not self.multiline then love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) end - -- Draw selection background + -- Draw selection background rectangles love.graphics.setColor(selectionWithOpacity:toRGBA()) - love.graphics.rectangle("fill", selX, selY, selWidth, selHeight) - - -- Redraw selected text on top - love.graphics.setColor(textColorWithOpacity:toRGBA()) - love.graphics.print(selectedText, selX, selY) + for _, rect in ipairs(selectionRects) do + local rectX = contentX + rect.x + local rectY = contentY + 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 -- Reset scissor if not self.multiline then @@ -4769,6 +4758,158 @@ function Element:_getCursorScreenPosition() 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 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) function Element:_updateAutoGrowHeight() if not self.editable or not self.multiline or not self.autoGrow then @@ -4845,31 +4986,128 @@ function Element:_mouseToTextPosition(mouseX, mouseY) local contentX = (self._absoluteX or self.x) + self.padding.left 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 - - -- Account for horizontal scroll offset in single-line inputs - if not self.multiline and self._textScrollX then - relativeX = relativeX + self._textScrollX - end + local relativeY = mouseY - contentY -- Get font for measuring text local font = self:_getFont() + if not font then + return 0 + end - -- Find the character position closest to the click 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 + -- Get text up to this position + local offset = utf8.offset(text, i + 1) + local beforeText = offset and text:sub(1, offset - 1) or text + local textWidth = font:getWidth(beforeText) + + -- Calculate distance from click to this position + 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.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 - -- Check each position in the text - for i = 0, textLength do - -- Get text up to this position - local offset = utf8.offset(text, i + 1) - local beforeText = offset and text:sub(1, offset - 1) or text + 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) - - -- Calculate distance from click to this position local dist = math.abs(relativeX - textWidth) if dist < closestDist then @@ -4878,7 +5116,7 @@ function Element:_mouseToTextPosition(mouseX, mouseY) end end - return closestPos + return charOffset + closestPos end --- Handle mouse click on text (set cursor position or start selection) diff --git a/testing/__tests__/33_input_field_tests.lua b/testing/__tests__/33_input_field_tests.lua index af742fb..f86d4d9 100644 --- a/testing/__tests__/33_input_field_tests.lua +++ b/testing/__tests__/33_input_field_tests.lua @@ -1776,5 +1776,313 @@ function TestInputField:testTextScrollWithBackspace() lu.assertTrue(element._textScrollX <= initialScroll) 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 lu.LuaUnit.run()