From 8206f96867f1b5c4785980495f7f50f4ee31d06d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Nov 2025 21:16:35 -0500 Subject: [PATCH] continued refactor --- modules/Element.lua | 286 ++------------------------------------- modules/LayoutEngine.lua | 80 ++++++++++- modules/Renderer.lua | 212 +++++++++++++++++++++++++++++ modules/TextEditor.lua | 199 +-------------------------- 4 files changed, 304 insertions(+), 473 deletions(-) diff --git a/modules/Element.lua b/modules/Element.lua index 81f78cc..9d62eb5 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -2095,95 +2095,18 @@ end --- Calculate text width for button ---@return number function Element:calculateTextWidth() - if self.text == nil then + if not self._layoutEngine then return 0 end - - if self.textSize then - -- Resolve font path from font family (same logic as in draw) - local fontPath = nil - if self.fontFamily then - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - fontPath = self._themeManager:getDefaultFontFamily() - end - - local tempFont = FONT_CACHE.get(self.textSize, fontPath) - local width = tempFont:getWidth(self.text) - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width - end - - local font = love.graphics.getFont() - local width = font:getWidth(self.text) - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width + return self._layoutEngine:calculateTextWidth() end ---@return number function Element:calculateTextHeight() - if self.text == nil then + if not self._layoutEngine then return 0 end - - -- Get the font - local font - if self.textSize then - -- Resolve font path from font family (same logic as in draw) - local fontPath = nil - if self.fontFamily then - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - fontPath = self._themeManager:getDefaultFontFamily() - end - font = FONT_CACHE.get(self.textSize, fontPath) - else - font = love.graphics.getFont() - end - - local height = font:getHeight() - - -- If text wrapping is enabled, calculate height based on wrapped lines - if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then - -- Calculate available width for wrapping - local availableWidth = self.width - - -- If width is not set or is 0, try to use parent's content width - if (not availableWidth or availableWidth <= 0) and self.parent then - -- Use parent's content width (excluding padding) - availableWidth = self.parent.width - end - - if availableWidth and availableWidth > 0 then - -- Get the wrapped text lines using getWrap (returns width and table of lines) - local wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth) - -- Height is line height * number of lines - height = height * #wrappedLines - end - end - - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then - height = height * self.contentAutoSizingMultiplier.height - end - - return height + return self._layoutEngine:calculateTextHeight() end function Element:calculateAutoWidth() @@ -2516,206 +2439,17 @@ end ---@param line string -- Line to wrap ---@param maxWidth number -- Maximum width in pixels ---@return table -- Array of wrapped line parts +--- Wrap a line of text (delegates to Renderer) +---@param line string The line of text to wrap +---@param maxWidth number Maximum width for wrapping +---@return table Array of {text, startIdx, endIdx} function Element:_wrapLine(line, maxWidth) - if not self.editable then - return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } - end - - local font = self:_getFont() - 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 - - if self.textWrap == "word" then - -- Tokenize into words and whitespace, preserving exact spacing - local tokens = {} - local pos = 1 - local lineLen = utf8.len(line) - - while pos <= lineLen do - -- Check if current position is whitespace - local char = getUtf8Char(line, pos) - if char:match("%s") then - -- Collect whitespace sequence - local wsStart = pos - while pos <= lineLen and getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "space", - text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wsStart - 1, - length = pos - wsStart, - }) - else - -- Collect word (non-whitespace sequence) - local wordStart = pos - while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "word", - text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wordStart - 1, - length = pos - wordStart, - }) - end - end - - -- Process tokens and wrap - local charPos = 0 -- Track our position in the original line - for i, token in ipairs(tokens) do - if token.type == "word" then - local testLine = currentLine .. token.text - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - -- Current line is full, wrap before this word - local currentLineLen = utf8.len(currentLine) - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + currentLineLen, - }) - startIdx = charPos - currentLine = token.text - charPos = charPos + token.length - - -- Check if the word itself is too long - if so, break it with character wrapping - if font:getWidth(token.text) > maxWidth then - local wordLen = utf8.len(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, 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(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, 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 - charPos = charPos + token.length - else - currentLine = testLine - charPos = charPos + token.length - end - else - -- It's whitespace - add to current line - currentLine = currentLine .. token.text - charPos = charPos + token.length - end - end - else - -- Character wrapping - local lineLength = utf8.len(line) - for i = 1, lineLength do - local char = getUtf8Char(line, i) - local testLine = currentLine .. char - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - currentLine = char - startIdx = i - 1 - else - currentLine = testLine - end - end - end - - -- Add remaining text - if currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - end - - -- Ensure at least one part - if #wrappedParts == 0 then - table.insert(wrappedParts, { - text = "", - startIdx = 0, - endIdx = 0, - }) - end - - return wrappedParts + return self._renderer:wrapLine(self, line, maxWidth) end ---@return love.Font function Element:_getFont() - -- Get font path from theme or element - local fontPath = nil - if self.fontFamily then - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - end - - return FONT_CACHE.getFont(self.textSize, fontPath) + return self._renderer:getFont(self) end -- ==================== diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua index 146cc18..e60719a 100644 --- a/modules/LayoutEngine.lua +++ b/modules/LayoutEngine.lua @@ -534,13 +534,89 @@ function LayoutEngine:layoutChildren() end end +--- Calculate text width +---@return number The calculated text width +function LayoutEngine:calculateTextWidth() + local element = self.element + + if element.text == nil then + return 0 + end + + if element.textSize then + -- Get font from Renderer (Phase 1 integration) + local font = element._renderer:getFont(element) + local width = font:getWidth(element.text) + -- Apply contentAutoSizingMultiplier if set + if element.contentAutoSizingMultiplier and element.contentAutoSizingMultiplier.width then + width = width * element.contentAutoSizingMultiplier.width + end + return width + end + + local font = love.graphics.getFont() + local width = font:getWidth(element.text) + -- Apply contentAutoSizingMultiplier if set + if element.contentAutoSizingMultiplier and element.contentAutoSizingMultiplier.width then + width = width * element.contentAutoSizingMultiplier.width + end + return width +end + +--- Calculate text height +---@return number The calculated text height +function LayoutEngine:calculateTextHeight() + local element = self.element + + if element.text == nil then + return 0 + end + + -- Get the font + local font + if element.textSize then + -- Get font from Renderer (Phase 1 integration) + font = element._renderer:getFont(element) + else + font = love.graphics.getFont() + end + + local height = font:getHeight() + + -- If text wrapping is enabled, calculate height based on wrapped lines + if element.textWrap and (element.textWrap == "word" or element.textWrap == "char" or element.textWrap == true) then + -- Calculate available width for wrapping + local availableWidth = element.width + + -- If width is not set or is 0, try to use parent's content width + if (not availableWidth or availableWidth <= 0) and element.parent then + -- Use parent's content width (excluding padding) + availableWidth = element.parent.width + end + + if availableWidth and availableWidth > 0 then + -- Get the wrapped text lines using getWrap (returns width and table of lines) + local wrappedWidth, wrappedLines = font:getWrap(element.text, availableWidth) + -- Height is line height * number of lines + height = height * #wrappedLines + end + end + + -- Apply contentAutoSizingMultiplier if set + if element.contentAutoSizingMultiplier and element.contentAutoSizingMultiplier.height then + height = height * element.contentAutoSizingMultiplier.height + end + + return height +end + --- Calculate auto width based on children ---@return number The calculated width function LayoutEngine:calculateAutoWidth() local element = self.element -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box - local contentWidth = element:calculateTextWidth() + local contentWidth = self:calculateTextWidth() if not element.children or #element.children == 0 then return contentWidth end @@ -580,7 +656,7 @@ end function LayoutEngine:calculateAutoHeight() local element = self.element - local height = element:calculateTextHeight() + local height = self:calculateTextHeight() if not element.children or #element.children == 0 then return height end diff --git a/modules/Renderer.lua b/modules/Renderer.lua index 7b844de..0117405 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -351,6 +351,218 @@ function Renderer:draw(backdropCanvas) self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight) end +--- Get font for element (resolves from theme or fontFamily) +---@param element table Reference to the parent Element instance +---@return love.Font +function Renderer:getFont(element) + -- Get font path from theme or element + local fontPath = nil + if element.fontFamily then + local themeToUse = element._themeManager:getTheme() + if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then + fontPath = themeToUse.fonts[element.fontFamily] + else + fontPath = element.fontFamily + end + end + + return FONT_CACHE.getFont(element.textSize, fontPath) +end + +--- Wrap a line of text based on element's textWrap mode +---@param element table Reference to the parent Element instance +---@param line string The line of text to wrap +---@param maxWidth number Maximum width for wrapping +---@return table Array of {text, startIdx, endIdx} +function Renderer:wrapLine(element, line, maxWidth) + -- UTF-8 support + local utf8 = utf8 or require("utf8") + + if not element.editable then + return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } + end + + local font = self:getFont(element) + 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 + + if element.textWrap == "word" then + -- Tokenize into words and whitespace, preserving exact spacing + local tokens = {} + local pos = 1 + local lineLen = utf8.len(line) + + while pos <= lineLen do + -- Check if current position is whitespace + local char = getUtf8Char(line, pos) + if char:match("%s") then + -- Collect whitespace sequence + local wsStart = pos + while pos <= lineLen and getUtf8Char(line, pos):match("%s") do + pos = pos + 1 + end + table.insert(tokens, { + type = "space", + text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), + startPos = wsStart - 1, + length = pos - wsStart, + }) + else + -- Collect word (non-whitespace sequence) + local wordStart = pos + while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do + pos = pos + 1 + end + table.insert(tokens, { + type = "word", + text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), + startPos = wordStart - 1, + length = pos - wordStart, + }) + end + end + + -- Process tokens and wrap + local charPos = 0 -- Track our position in the original line + for i, token in ipairs(tokens) do + if token.type == "word" then + local testLine = currentLine .. token.text + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + -- Current line is full, wrap before this word + local currentLineLen = utf8.len(currentLine) + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + currentLineLen, + }) + startIdx = charPos + currentLine = token.text + charPos = charPos + token.length + + -- Check if the word itself is too long - if so, break it with character wrapping + if font:getWidth(token.text) > maxWidth then + local wordLen = utf8.len(token.text) + local charLine = "" + local charStartIdx = startIdx + + for j = 1, wordLen do + local char = getUtf8Char(token.text, 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(token.text) + local charLine = "" + local charStartIdx = startIdx + + for j = 1, wordLen do + local char = getUtf8Char(token.text, 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 + charPos = charPos + token.length + else + currentLine = testLine + charPos = charPos + token.length + end + else + -- It's whitespace - add to current line + currentLine = currentLine .. token.text + charPos = charPos + token.length + end + end + else + -- Character wrapping + local lineLength = utf8.len(line) + for i = 1, lineLength do + local char = getUtf8Char(line, i) + local testLine = currentLine .. char + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + currentLine = char + startIdx = i - 1 + else + currentLine = testLine + end + end + end + + -- Add remaining text + if currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + end + + -- Ensure at least one part + if #wrappedParts == 0 then + table.insert(wrappedParts, { + text = "", + startIdx = 0, + endIdx = 0, + }) + end + + return wrappedParts +end + --- Draw text content (includes text, cursor, selection, placeholder, password masking) ---@param element table Reference to the parent Element instance function Renderer:drawText(element) diff --git a/modules/TextEditor.lua b/modules/TextEditor.lua index dffa643..4cb44e7 100644 --- a/modules/TextEditor.lua +++ b/modules/TextEditor.lua @@ -325,188 +325,8 @@ function TextEditor:_wrapLine(line, maxWidth) return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } end - local font = self:_getFont() - if not font then - return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } - end - - 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 - - if self.textWrap == "word" then - -- Tokenize into words and whitespace, preserving exact spacing - local tokens = {} - local pos = 1 - local lineLen = utf8.len(line) - - while pos <= lineLen do - local char = getUtf8Char(line, pos) - if char:match("%s") then - -- Collect whitespace sequence - local wsStart = pos - while pos <= lineLen and getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "space", - text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wsStart - 1, - length = pos - wsStart, - }) - else - -- Collect word - local wordStart = pos - while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "word", - text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wordStart - 1, - length = pos - wordStart, - }) - end - end - - -- Process tokens and wrap - local charPos = 0 - for i, token in ipairs(tokens) do - if token.type == "word" then - local testLine = currentLine .. token.text - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - -- Current line is full, wrap before this word - local currentLineLen = utf8.len(currentLine) - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + currentLineLen, - }) - startIdx = charPos - currentLine = token.text - charPos = charPos + token.length - - -- Check if the word itself is too long - if font:getWidth(token.text) > maxWidth then - local wordLen = utf8.len(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, 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 - local wordLen = utf8.len(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, 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 - charPos = charPos + token.length - else - currentLine = testLine - charPos = charPos + token.length - end - else - -- It's whitespace - add to current line - currentLine = currentLine .. token.text - charPos = charPos + token.length - end - end - else - -- Character wrapping - local lineLength = utf8.len(line) - for i = 1, lineLength do - local char = getUtf8Char(line, i) - local testLine = currentLine .. char - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - currentLine = char - startIdx = i - 1 - else - currentLine = testLine - end - end - end - - -- Add remaining text - if currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - end - - -- Ensure at least one part - if #wrappedParts == 0 then - table.insert(wrappedParts, { - text = "", - startIdx = 0, - endIdx = 0, - }) - end - - return wrappedParts + -- Delegate to Renderer + return self._element._renderer:wrapLine(self._element, line, maxWidth) end -- ==================== @@ -1712,19 +1532,8 @@ function TextEditor:_getFont() return nil end - -- Resolve font path - local fontPath = nil - if self._element.fontFamily then - local Theme = req("Theme") - local themeToUse = self._element.theme and Theme.get(self._element.theme) or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self._element.fontFamily] then - fontPath = themeToUse.fonts[self._element.fontFamily] - else - fontPath = self._element.fontFamily - end - end - - return FONT_CACHE.getFont(self._element.textSize, fontPath) + -- Delegate to Renderer + return self._element._renderer:getFont(self._element) end ---Save state to StateManager (for immediate mode)