From 3f17ed7d2911a8bc400df6adc4708a3e604fe7a3 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 7 Nov 2025 17:44:18 -0500 Subject: [PATCH] bug in backspace - but multiline works --- examples/16-full-imput-demo.lua | 52 +++--- modules/Element.lua | 309 ++++++++++++++++++++++++++++++-- 2 files changed, 320 insertions(+), 41 deletions(-) diff --git a/examples/16-full-imput-demo.lua b/examples/16-full-imput-demo.lua index 38b0f38..c56d15a 100644 --- a/examples/16-full-imput-demo.lua +++ b/examples/16-full-imput-demo.lua @@ -4,7 +4,7 @@ Simple input field demo - multiple fields to test all features Uses retained mode - elements are created once and reused --]] -local FlexLove = require("FlexLove") +local FlexLove = require("libs.FlexLove") local Element = FlexLove.Element local Color = FlexLove.Color @@ -20,11 +20,20 @@ local function initialize() return end 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 elements.title = Element.new({ - x = 50, - y = 50, + parent = elements.container, width = 700, height = 40, text = "FlexLove Input Field Demo", @@ -35,8 +44,7 @@ local function initialize() -- Input field 1 - Empty with placeholder elements.inputField1 = Element.new({ - x = 50, - y = 120, + parent = elements.container, width = 600, height = 50, editable = true, @@ -48,17 +56,17 @@ local function initialize() padding = { horizontal = 15, vertical = 12 }, placeholder = "Type here... (empty field with placeholder)", selectOnFocus = false, + multiline = true, + autoGrow = true, z = 1000, + onTextChange = function(element, newText) + print("Field 1 changed:", newText) + end, }) - elements.inputField1.onTextChange = function(element, newText) - print("Field 1 changed:", newText) - end - -- Input field 2 - Pre-filled with selectOnFocus elements.inputField2 = Element.new({ - x = 50, - y = 200, + parent = elements.container, width = 600, height = 50, editable = true, @@ -71,16 +79,13 @@ local function initialize() placeholder = "This shouldn't show", selectOnFocus = true, 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({ - x = 50, - y = 280, + parent = elements.container, width = 600, height = 50, editable = true, @@ -94,16 +99,13 @@ local function initialize() maxLength = 20, selectOnFocus = false, 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({ - x = 50, - y = 360, + parent = elements.container, width = 700, 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", diff --git a/modules/Element.lua b/modules/Element.lua index 2d2e3df..1600144 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -309,7 +309,12 @@ function Element.new(props) if self.scrollable == nil then self.scrollable = self.multiline 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 -- Cursor and selection properties @@ -2581,11 +2586,18 @@ function Element:draw(backdropCanvas) -- Draw element text if present -- 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 -- 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 isPlaceholder = true end @@ -2707,18 +2719,17 @@ function Element:draw(backdropCanvas) local cursorWithOpacity = Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self.opacity) love.graphics.setColor(cursorWithOpacity:toRGBA()) - -- Calculate cursor position - local cursorText = "" - if self.text and self.text ~= "" and self._cursorPosition > 0 then - local byteOffset = utf8.offset(self.text, self._cursorPosition + 1) - 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 + -- Calculate cursor position using new method that handles multiline + local cursorRelX, cursorRelY = self:_getCursorScreenPosition() + local cursorX = contentX + cursorRelX + local cursorY = contentY + 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(contentX, contentY, textAreaWidth, textAreaHeight) @@ -4314,6 +4325,8 @@ function Element:setText(text) self._textBuffer = text or "" self.text = self._textBuffer -- Sync display text self:_markTextDirty() + self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content self:_validateCursorPosition() end @@ -4352,7 +4365,12 @@ function Element:insertText(text, position) -- Update cursor position 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:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content self:_validateCursorPosition() end @@ -4386,6 +4404,8 @@ function Element:deleteText(startPos, endPos) self.text = self._textBuffer -- Sync display text self:_markTextDirty() + self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content end --- Replace text in range @@ -4487,6 +4507,21 @@ function Element:_wrapLine(line, maxWidth) 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 + + 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 -- Word wrapping @@ -4501,13 +4536,68 @@ function Element:_wrapLine(line, maxWidth) if width > maxWidth and currentLine ~= "" then -- Current line is full, start new line + local currentLineLen = utf8.len(currentLine) table.insert(wrappedParts, { text = currentLine, startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), + endIdx = startIdx + currentLineLen, }) + startIdx = startIdx + currentLineLen + 1 -- +1 for the space 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 currentLine = testLine end @@ -4516,7 +4606,7 @@ function Element:_wrapLine(line, maxWidth) -- Character wrapping local lineLength = utf8.len(line) for i = 1, lineLength do - local char = utf8.sub(line, i, i) + local char = getUtf8Char(line, i) local testLine = currentLine .. char local width = font:getWidth(testLine) @@ -4551,6 +4641,11 @@ function Element:_wrapLine(line, maxWidth) endIdx = 0, }) 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 end @@ -4573,6 +4668,188 @@ function Element:_getFont() return FONT_CACHE.getFont(self.textSize, fontPath) 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 -- ====================