From d2f205edd5c6aeb3a284bb47a099037cd0abe5ca Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 7 Nov 2025 14:10:43 -0500 Subject: [PATCH] single line input buffer completed --- modules/Element.lua | 395 ++++++++- testing/__tests__/33_input_field_tests.lua | 878 +++++++++++++++++++++ testing/loveStub.lua | 12 + 3 files changed, 1246 insertions(+), 39 deletions(-) diff --git a/modules/Element.lua b/modules/Element.lua index 98f2c4c..9498cd7 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -3205,6 +3205,11 @@ function Element:update(dt) }) self.callback(self, dragEvent) end + + -- Handle text selection drag for editable elements + if button == 1 and self.editable and self._focused then + self:_handleTextDrag(mx, my) + end -- Update last known position for this button self._lastMouseX[button] = mx @@ -3255,11 +3260,19 @@ function Element:update(dt) -- Clean up drag tracking self._dragStartX[button] = nil self._dragStartY[button] = nil + + -- Clean up text selection drag tracking + if button == 1 then + self._mouseDownPosition = nil + end -- Focus editable elements on left click if button == 1 and self.editable then print("[Element:update] Calling focus on editable element") self:focus() + + -- Handle text click for cursor positioning and word selection + self:_handleTextClick(mx, my, clickCount) elseif button == 1 then print("[Element:update] Button 1 clicked but editable:", self.editable) end @@ -3915,6 +3928,81 @@ function Element:moveCursorToLineEnd() self:moveCursorToEnd() end +--- Move cursor to start of previous word +function Element:moveCursorToPreviousWord() + if not self.editable or not self._textBuffer then + return + end + + local text = self._textBuffer + local pos = self._cursorPosition + + if pos <= 0 then + return + end + + -- Skip any whitespace/punctuation before current position + while pos > 0 do + local offset = utf8.offset(text, pos) + local char = offset and text:sub(offset, utf8.offset(text, pos + 1) - 1) or "" + if char:match("[%w]") then + break + end + pos = pos - 1 + end + + -- Move to start of current word + while pos > 0 do + local offset = utf8.offset(text, pos) + local char = offset and text:sub(offset, utf8.offset(text, pos + 1) - 1) or "" + if 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 Element:moveCursorToNextWord() + if not self.editable or 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 + + -- Skip current word + while pos < textLength do + local offset = utf8.offset(text, pos + 1) + local char = offset and text:sub(offset, utf8.offset(text, pos + 2) - 1) or "" + if not char:match("[%w]") then + break + end + pos = pos + 1 + end + + -- Skip any whitespace/punctuation + while pos < textLength do + local offset = utf8.offset(text, pos + 1) + local char = offset and text:sub(offset, utf8.offset(text, pos + 2) - 1) or "" + if 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 Element:_validateCursorPosition() if not self.editable then @@ -4396,6 +4484,152 @@ function Element:_getFont() return FONT_CACHE.getFont(self.textSize, fontPath) end +-- ==================== +-- Input Handling - 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 Element:_mouseToTextPosition(mouseX, mouseY) + if not self.editable or not self._textBuffer then + return 0 + end + + -- Get content area bounds + 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 + local relativeX = mouseX - contentX + + -- Get font for measuring text + local font = self:_getFont() + + -- Find the character position closest to the click + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + 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 + +--- 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 Element:_handleTextClick(mouseX, mouseY, clickCount) + if not self.editable or 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 (or line in multi-line mode) + 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 Element:_handleTextDrag(mouseX, mouseY) + if not self.editable or 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 Element:_selectWordAtPosition(position) + if not self.editable or 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 + -- ==================== -- Input Handling - Keyboard Input -- ==================== @@ -4446,48 +4680,81 @@ function Element:keypressed(key, scancode, isrepeat) local modifiers = getModifiers() local ctrl = modifiers.ctrl or modifiers.super -- Support both Ctrl and Cmd - -- Handle cursor movement - if key == "left" then - if self:hasSelection() and not modifiers.shift then - -- Move to start of selection - local startPos, _ = self:getSelection() - self._cursorPosition = startPos - self:clearSelection() - else - self:moveCursorBy(-1) + -- 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 - self:_resetCursorBlink() - elseif key == "right" then - if self:hasSelection() and not modifiers.shift then - -- Move to end of selection - local _, endPos = self:getSelection() - self._cursorPosition = endPos - self:clearSelection() - else - self:moveCursorBy(1) + + -- Store old cursor position + local oldCursorPos = self._cursorPosition + + -- Move cursor based on key + if key == "left" then + if self:hasSelection() and not modifiers.shift then + -- Move to start of selection + local startPos, _ = self:getSelection() + self._cursorPosition = startPos + self:clearSelection() + elseif ctrl then + -- Ctrl+Left: Move to previous word + self:moveCursorToPreviousWord() + else + self:moveCursorBy(-1) + end + elseif key == "right" then + if self:hasSelection() and not modifiers.shift then + -- Move to end of selection + local _, endPos = self:getSelection() + self._cursorPosition = endPos + self:clearSelection() + elseif ctrl then + -- Ctrl+Right: Move to next word + self:moveCursorToNextWord() + else + self:moveCursorBy(1) + end + elseif key == "home" then + -- Move to line start (or document start for single-line) + if ctrl or not self.multiline then + self:moveCursorToStart() + else + self:moveCursorToLineStart() + end + if not modifiers.shift then + self:clearSelection() + end + elseif key == "end" then + -- Move to line end (or document end for single-line) + if ctrl or 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 - self:_resetCursorBlink() - elseif key == "home" or (ctrl and key == "a" and not self.multiline) then - -- Move to line start (or document start for single-line) - if ctrl or not self.multiline then - self:moveCursorToStart() - else - self:moveCursorToLineStart() - end - if key == "home" then - self:clearSelection() - end - self:_resetCursorBlink() - elseif key == "end" or (ctrl and key == "e" and not self.multiline) then - -- Move to line end (or document end for single-line) - if ctrl or not self.multiline then - self:moveCursorToEnd() - else - self:moveCursorToLineEnd() - end - if key == "end" then - self:clearSelection() + + -- Update selection if Shift is pressed + if modifiers.shift and self._selectionAnchor then + self:setSelection(self._selectionAnchor, self._cursorPosition) + elseif not modifiers.shift then + -- Clear anchor when Shift is released + self._selectionAnchor = nil end + self:_resetCursorBlink() -- Handle backspace and delete @@ -4554,6 +4821,56 @@ function Element:keypressed(key, scancode, isrepeat) 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) + + -- Delete the selected text + local oldText = self._textBuffer + self:deleteSelection() + + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end + end + end + self:_resetCursorBlink() + + -- 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 + + -- Delete selection if exists + if self:hasSelection() then + self:deleteSelection() + end + + -- Insert clipboard text + self:insertText(clipboardText) + + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end + end + self:_resetCursorBlink() + -- Handle Escape elseif key == "escape" then if self:hasSelection() then diff --git a/testing/__tests__/33_input_field_tests.lua b/testing/__tests__/33_input_field_tests.lua index 9dced14..4672914 100644 --- a/testing/__tests__/33_input_field_tests.lua +++ b/testing/__tests__/33_input_field_tests.lua @@ -586,5 +586,883 @@ function TestInputField:testPasswordModeDisablesMultiline() lu.assertFalse(element.multiline) end +-- ==================== +-- Keyboard Selection Tests +-- ==================== + +function TestInputField:testShiftRightSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + 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 + + -- Shift+Right should select one character + element:keypressed("right", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 1) + + -- Another Shift+Right should extend selection + element:keypressed("right", nil, false) + lu.assertTrue(element:hasSelection()) + startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 2) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testShiftLeftSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setCursorPosition(5) -- Position after "Hello" + + -- 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 + + -- Shift+Left should select one character backwards + element:keypressed("left", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 4) + lu.assertEquals(endPos, 5) + + -- Another Shift+Left should extend selection + element:keypressed("left", nil, false) + lu.assertTrue(element:hasSelection()) + startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 3) + lu.assertEquals(endPos, 5) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testShiftHomeSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setCursorPosition(5) + + -- 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 + + -- Shift+Home should select from cursor to start + element:keypressed("home", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 5) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testShiftEndSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setCursorPosition(5) + + -- 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 + + -- Shift+End should select from cursor to end + element:keypressed("end", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 5) + lu.assertEquals(endPos, 11) -- "Hello World" has 11 characters + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testSelectionDirectionChange() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setCursorPosition(5) + + -- 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 + + -- Select right + element:keypressed("right", nil, false) + element:keypressed("right", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 5) + lu.assertEquals(endPos, 7) + + -- Now select left (should shrink selection) + element:keypressed("left", nil, false) + lu.assertTrue(element:hasSelection()) + startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 5) + lu.assertEquals(endPos, 6) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testArrowWithoutShiftClearsSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setSelection(0, 5) + lu.assertTrue(element:hasSelection()) + + -- Arrow key without Shift should clear selection and move cursor + element:keypressed("right", nil, false) + lu.assertFalse(element:hasSelection()) + lu.assertEquals(element._cursorPosition, 5) -- Should move to end of selection +end + +function TestInputField:testTypingReplacesSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setSelection(0, 5) -- Select "Hello" + + -- Type a character - should replace selection + element:textinput("X") + lu.assertEquals(element:getText(), "X World") + lu.assertFalse(element:hasSelection()) + lu.assertEquals(element._cursorPosition, 1) +end + +-- ==================== +-- Mouse Selection Tests +-- ==================== + +function TestInputField:testMouseClickSetsCursorPosition() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + + -- Simulate single click (this would normally be done through the event system) + -- We'll test the _handleTextClick method directly + element:_handleTextClick(15, 15, 1) -- Single click near start + + -- Cursor should be set (exact position depends on font, so we just check it's valid) + lu.assertTrue(element._cursorPosition >= 0) + lu.assertTrue(element._cursorPosition <= 11) + lu.assertFalse(element:hasSelection()) +end + +function TestInputField:testMouseDoubleClickSelectsWord() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setCursorPosition(3) -- Position in "Hello" + + -- Simulate double click to select word + element:_handleTextClick(15, 15, 2) -- Double click + + -- Should have selected a word (we can't test exact positions without font metrics) + -- But we can verify a selection was created + lu.assertTrue(element:hasSelection() or element._cursorPosition >= 0) +end + +function TestInputField:testMouseTripleClickSelectsAll() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + + -- Simulate triple click + element:_handleTextClick(15, 15, 3) -- Triple click + + -- Should select all text + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 11) +end + +function TestInputField:testMouseDragCreatesSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + + -- Simulate mouse down at position 0 + element._mouseDownPosition = 0 + + -- Simulate drag to position 5 + element:_handleTextDrag(50, 15) + + -- Should have created a selection (exact positions depend on font metrics) + -- We just verify the drag handler works + lu.assertTrue(element._cursorPosition >= 0) +end + +function TestInputField:testSelectWordAtPosition() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World Test", + }) + + element:focus() + + -- Select word at position 6 (in "World") + element:_selectWordAtPosition(6) + + -- Should have selected "World" + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 6) + lu.assertEquals(endPos, 11) + lu.assertEquals(element:getSelectedText(), "World") +end + +function TestInputField:testSelectWordWithNonAlphanumeric() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello, World!", + }) + + element:focus() + + -- Select word at position 0 (in "Hello") + element:_selectWordAtPosition(2) + + -- Should have selected "Hello" (not including comma) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 5) + lu.assertEquals(element:getSelectedText(), "Hello") +end + +function TestInputField:testMouseToTextPosition() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello", + }) + + element:focus() + + -- Test conversion at start of element + local pos = element:_mouseToTextPosition(10, 10) + lu.assertEquals(pos, 0) + + -- Test conversion far to the right (should be at end) + pos = element:_mouseToTextPosition(200, 10) + lu.assertEquals(pos, 5) -- "Hello" has 5 characters +end + +-- ==================== +-- Clipboard Operations Tests +-- ==================== + +function TestInputField:testCtrlCCopiesSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setSelection(0, 5) -- Select "Hello" + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+C + element:keypressed("c", nil, false) + + -- Check clipboard content + lu.assertEquals(love.system.getClipboardText(), "Hello") + + -- Text should remain unchanged + lu.assertEquals(element:getText(), "Hello World") + lu.assertTrue(element:hasSelection()) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlXCutsSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setSelection(0, 5) -- Select "Hello" + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+X + element:keypressed("x", nil, false) + + -- Check clipboard content + lu.assertEquals(love.system.getClipboardText(), "Hello") + + -- Text should be cut + lu.assertEquals(element:getText(), " World") + lu.assertFalse(element:hasSelection()) + lu.assertEquals(element._cursorPosition, 0) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlVPastesFromClipboard() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "World", + }) + + element:focus() + element:setCursorPosition(0) + + -- Set clipboard content + love.system.setClipboardText("Hello ") + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+V + element:keypressed("v", nil, false) + + -- Text should be pasted + lu.assertEquals(element:getText(), "Hello World") + lu.assertEquals(element._cursorPosition, 6) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlVReplacesSelection() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + element:setSelection(6, 11) -- Select "World" + + -- Set clipboard content + love.system.setClipboardText("Everyone") + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+V + element:keypressed("v", nil, false) + + -- Selection should be replaced + lu.assertEquals(element:getText(), "Hello Everyone") + lu.assertFalse(element:hasSelection()) + lu.assertEquals(element._cursorPosition, 14) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCopyWithoutSelectionDoesNothing() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + + -- Clear clipboard + love.system.setClipboardText("") + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+C without selection + element:keypressed("c", nil, false) + + -- Clipboard should remain empty + lu.assertEquals(love.system.getClipboardText(), "") + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCutWithoutSelectionDoesNothing() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World", + }) + + element:focus() + + -- Clear clipboard + love.system.setClipboardText("") + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+X without selection + element:keypressed("x", nil, false) + + -- Clipboard should remain empty and text unchanged + lu.assertEquals(love.system.getClipboardText(), "") + lu.assertEquals(element:getText(), "Hello World") + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testPasteEmptyClipboard() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello", + }) + + element:focus() + element:setCursorPosition(5) + + -- Clear clipboard + love.system.setClipboardText("") + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Simulate Ctrl+V with empty clipboard + element:keypressed("v", nil, false) + + -- Text should remain unchanged + lu.assertEquals(element:getText(), "Hello") + lu.assertEquals(element._cursorPosition, 5) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +-- ==================== +-- Word Navigation Tests +-- ==================== + +function TestInputField:testCtrlLeftMovesToPreviousWord() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World Test", + }) + + element:focus() + element:setCursorPosition(16) -- At end of text + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Ctrl+Left should move to start of "Test" + element:keypressed("left", nil, false) + lu.assertEquals(element._cursorPosition, 12) + + -- Another Ctrl+Left should move to start of "World" + element:keypressed("left", nil, false) + lu.assertEquals(element._cursorPosition, 6) + + -- Another Ctrl+Left should move to start of "Hello" + element:keypressed("left", nil, false) + lu.assertEquals(element._cursorPosition, 0) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlRightMovesToNextWord() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World Test", + }) + + element:focus() + element:setCursorPosition(0) -- At start of text + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Ctrl+Right should move to start of "World" + element:keypressed("right", nil, false) + lu.assertEquals(element._cursorPosition, 6) + + -- Another Ctrl+Right should move to start of "Test" + element:keypressed("right", nil, false) + lu.assertEquals(element._cursorPosition, 12) + + -- Another Ctrl+Right should move to end + element:keypressed("right", nil, false) + lu.assertEquals(element._cursorPosition, 16) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlShiftLeftSelectsWord() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World Test", + }) + + element:focus() + element:setCursorPosition(16) -- At end of text + + -- Mock Ctrl+Shift keys + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" or key == "lshift" or key == "rshift" then + return true + end + end + return false + end + + -- Ctrl+Shift+Left should select "Test" + element:keypressed("left", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 12) + lu.assertEquals(endPos, 16) + lu.assertEquals(element:getSelectedText(), "Test") + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testCtrlShiftRightSelectsWord() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello World Test", + }) + + element:focus() + element:setCursorPosition(0) -- At start of text + + -- Mock Ctrl+Shift keys + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" or key == "lshift" or key == "rshift" then + return true + end + end + return false + end + + -- Ctrl+Shift+Right should select "Hello" + element:keypressed("right", nil, false) + lu.assertTrue(element:hasSelection()) + local startPos, endPos = element:getSelection() + lu.assertEquals(startPos, 0) + lu.assertEquals(endPos, 6) + lu.assertEquals(element:getSelectedText(), "Hello ") + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + +function TestInputField:testWordNavigationWithPunctuation() + local element = FlexLove.Element.new({ + x = 10, + y = 10, + width = 100, + height = 30, + editable = true, + text = "Hello, World! Test.", + }) + + element:focus() + element:setCursorPosition(0) + + -- Mock Ctrl key + local oldIsDown = _G.love.keyboard.isDown + _G.love.keyboard.isDown = function(...) + local keys = {...} + for _, key in ipairs(keys) do + if key == "lctrl" or key == "rctrl" then + return true + end + end + return false + end + + -- Ctrl+Right should skip punctuation and move to "World" + element:keypressed("right", nil, false) + lu.assertEquals(element._cursorPosition, 7) + + -- Another Ctrl+Right should move to "Test" + element:keypressed("right", nil, false) + lu.assertEquals(element._cursorPosition, 14) + + -- Reset mock + _G.love.keyboard.isDown = oldIsDown +end + -- Run tests lu.LuaUnit.run() diff --git a/testing/loveStub.lua b/testing/loveStub.lua index 25bf6ee..68007a0 100644 --- a/testing/loveStub.lua +++ b/testing/loveStub.lua @@ -412,5 +412,17 @@ function love_helper.filesystem.addMockFile(path, data) } end +-- Mock system clipboard +love_helper.system = {} +local mockClipboard = "" + +function love_helper.system.getClipboardText() + return mockClipboard +end + +function love_helper.system.setClipboardText(text) + mockClipboard = text or "" +end + _G.love = love_helper return love_helper