continued refactor

This commit is contained in:
Michael Freno
2025-11-12 21:16:35 -05:00
parent 3df8718a62
commit 8206f96867
4 changed files with 304 additions and 473 deletions

View File

@@ -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
-- ====================

View File

@@ -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

View File

@@ -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)

View File

@@ -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)