begin major Element refactor

This commit is contained in:
Michael Freno
2025-11-19 15:37:08 -05:00
parent 8025d29ab6
commit e778815c5e
9 changed files with 421 additions and 498 deletions

View File

@@ -24,7 +24,6 @@ local LayoutEngine = req("LayoutEngine")
local Renderer = req("Renderer")
local EventHandler = req("EventHandler")
local ScrollManager = req("ScrollManager")
local ImageDataReader = req("ImageDataReader")
---@type ErrorHandler
local ErrorHandler = req("ErrorHandler")
---@type Element
@@ -123,12 +122,12 @@ function flexlove.init(config)
ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler })
NinePatch.init({ ErrorHandler = flexlove._ErrorHandler })
ImageDataReader.init({ ErrorHandler = flexlove._ErrorHandler })
Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler })
Color.init({ ErrorHandler = flexlove._ErrorHandler })
utils.init({ ErrorHandler = flexlove._ErrorHandler })
Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color })
Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils })
flexlove._defaultDependencies = {
Context = Context,
@@ -152,6 +151,7 @@ function flexlove.init(config)
EventHandler = EventHandler,
ScrollManager = ScrollManager,
ErrorHandler = flexlove._ErrorHandler,
Performance = flexlove._Performance,
}
if config.baseScale then

View File

@@ -1,11 +1,20 @@
---@class Color
---@field r number -- Red component (0-1)
---@field g number -- Green component (0-1)
---@field b number -- Blue component (0-1)
---@field a number -- Alpha component (0-1)
---@field r number Red component (0-1)
---@field g number Green component (0-1)
---@field b number Blue component (0-1)
---@field a number Alpha component (0-1)
---@field _ErrorHandler table? ErrorHandler module dependency
local Color = {}
Color.__index = Color
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Color.init(deps)
if type(deps) == "table" then
Color._ErrorHandler = deps.ErrorHandler
end
end
--- Build type-safe color objects with automatic validation and clamping
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
---@param r number? Red component (0-1), defaults to 0
@@ -46,7 +55,7 @@ end
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
Color._ErrorHandler:warn("Color", "VAL_004", "Invalid color format", {
input = tostring(hexWithTag),
issue = "not a string",
fallback = "white (#FFFFFF)",
@@ -60,7 +69,7 @@ function Color.fromHex(hexWithTag)
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then
Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
Color._ErrorHandler:warn("Color", "VAL_004", "Invalid color format", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFF)",
@@ -74,7 +83,7 @@ function Color.fromHex(hexWithTag)
local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then
Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
Color._ErrorHandler:warn("Color", "VAL_004", "Invalid color format", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFFFF)",
@@ -83,7 +92,7 @@ function Color.fromHex(hexWithTag)
end
return Color.new(r / 255, g / 255, b / 255, a / 255)
else
Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
Color._ErrorHandler:warn("Color", "VAL_004", "Invalid color format", {
input = hexWithTag,
expected = "#RRGGBB or #RRGGBBAA",
hexLength = #hex,
@@ -337,10 +346,4 @@ function Color.lerp(colorA, colorB, t)
return Color.new(r, g, b, a)
end
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
function Color.init(deps)
Color._ErrorHandler = deps.ErrorHandler
end
return Color

View File

@@ -157,11 +157,6 @@ Element.__index = Element
---@param deps table Required dependency table (provided by FlexLove)
---@return Element
function Element.new(props, deps)
if not deps then
-- Can't use ErrorHandler yet since deps contains it
error("[FlexLove - Element] Error: deps parameter is required. Pass Element.defaultDependencies from FlexLove.")
end
local self = setmetatable({}, Element)
self._deps = deps
@@ -2855,18 +2850,16 @@ function Element:countElements()
return count
end
--- Check and warn about performance issues in element hierarchy
function Element:_checkPerformanceWarnings()
-- Check if performance warnings are enabled
local Performance = self._deps and (package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"])
if not Performance or not Performance.areWarningsEnabled() then
local Performance = self._deps and self._deps.Performance
if not Performance or not Performance.warningsEnabled then
return
end
-- Check hierarchy depth
local depth = self:getHierarchyDepth()
if depth >= 15 then
Performance.logWarning(
Performance:logWarning(
string.format("hierarchy_depth_%s", self.id),
"Element",
string.format("Element hierarchy depth is %d levels for element '%s'", depth, self.id or "unnamed"),
@@ -2879,7 +2872,7 @@ function Element:_checkPerformanceWarnings()
if not self.parent then
local totalElements = self:countElements()
if totalElements >= 1000 then
Performance.logWarning(
Performance:logWarning(
"element_count_high",
"Element",
string.format("UI contains %d+ elements", totalElements),
@@ -2902,14 +2895,15 @@ end
--- Track active animations and warn if too many
function Element:_trackActiveAnimations()
local Performance = self._deps and (package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"])
if not Performance or not Performance.areWarningsEnabled() then
-- Get Performance instance from deps if available
local Performance = self._deps and self._deps.Performance
if not Performance or not Performance.warningsEnabled then
return
end
local animCount = self:_countActiveAnimations()
if animCount >= 50 then
Performance.logWarning(
Performance:logWarning(
"animation_count_high",
"Element",
string.format("%d+ animations running simultaneously", animCount),

View File

@@ -1,111 +0,0 @@
local ImageDataReader = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize ImageDataReader with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageDataReader.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
---@param imagePath string
---@return love.ImageData
function ImageDataReader.loadImageData(imagePath)
if not imagePath then
ErrorHandler.error("ImageDataReader", "VAL_001", "Image path cannot be nil")
end
local success, result = pcall(function()
return love.image.newImageData(imagePath)
end)
if not success then
ErrorHandler.error("ImageDataReader", "RES_001", "Failed to load image data from '" .. imagePath .. "': " .. tostring(result), {
imagePath = imagePath,
error = tostring(result),
})
end
return result
end
--- Extract all pixels from a specific row
---@param imageData love.ImageData
---@param rowIndex number -- 0-based row index
---@return table -- Array of {r, g, b, a} values (0-255 range)
function ImageDataReader.getRow(imageData, rowIndex)
if not imageData then
ErrorHandler.error("ImageDataReader", "VAL_001", "ImageData cannot be nil")
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if rowIndex < 0 or rowIndex >= height then
ErrorHandler.error("ImageDataReader", "VAL_002", string.format("Row index %d out of bounds (height: %d)", rowIndex, height), {
rowIndex = rowIndex,
height = height,
})
end
local pixels = {}
for x = 0, width - 1 do
local r, g, b, a = imageData:getPixel(x, rowIndex)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Extract all pixels from a specific column
---@param imageData love.ImageData
---@param colIndex number -- 0-based column index
---@return table -- Array of {r, g, b, a} values (0-255 range)
function ImageDataReader.getColumn(imageData, colIndex)
if not imageData then
ErrorHandler.error("ImageDataReader", "VAL_001", "ImageData cannot be nil")
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if colIndex < 0 or colIndex >= width then
ErrorHandler.error("ImageDataReader", "VAL_002", string.format("Column index %d out of bounds (width: %d)", colIndex, width), {
colIndex = colIndex,
width = width,
})
end
local pixels = {}
for y = 0, height - 1 do
local r, g, b, a = imageData:getPixel(colIndex, y)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Check if a pixel is black with full alpha (9-patch marker)
---@param r number -- Red (0-255)
---@param g number -- Green (0-255)
---@param b number -- Blue (0-255)
---@param a number -- Alpha (0-255)
---@return boolean
function ImageDataReader.isBlackPixel(r, g, b, a)
return r == 0 and g == 0 and b == 0 and a == 255
end
return ImageDataReader

View File

@@ -1,168 +0,0 @@
--- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message
---@return string -- Formatted error message
local function formatError(module, message)
return string.format("[FlexLove.%s] %s", module, message)
end
-- ====================
-- Dependencies
-- ====================
local ImageDataReader = require((...):match("(.-)[^%.]+$") .. "ImageDataReader")
-- ====================
-- NinePatchParser
-- ====================
local NinePatchParser = {}
--- Find all continuous runs of black pixels in a pixel array
---@param pixels table -- Array of {r, g, b, a} pixel values
---@return table -- Array of {start, end} pairs (1-based indices, inclusive)
local function findBlackPixelRuns(pixels)
local runs = {}
local inRun = false
local runStart = nil
for i = 1, #pixels do
local pixel = pixels[i]
local isBlack = ImageDataReader.isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a)
if isBlack and not inRun then
-- Start of a new run
inRun = true
runStart = i
elseif not isBlack and inRun then
-- End of current run
table.insert(runs, { start = runStart, ["end"] = i - 1 })
inRun = false
runStart = nil
end
end
-- Handle case where run extends to end of array
if inRun then
table.insert(runs, { start = runStart, ["end"] = #pixels })
end
return runs
end
--- Parse a 9-patch PNG image to extract stretch regions and content padding
---@param imagePath string -- Path to the 9-patch image file
---@return table|nil, string|nil -- Returns {insets, stretchX, stretchY} or nil, error message
function NinePatchParser.parse(imagePath)
if not imagePath then
return nil, "Image path cannot be nil"
end
local success, imageData = pcall(function()
return ImageDataReader.loadImageData(imagePath)
end)
if not success then
return nil, "Failed to load image data: " .. tostring(imageData)
end
local width = imageData:getWidth()
local height = imageData:getHeight()
-- Validate minimum size (must be at least 3x3 with 1px border)
if width < 3 or height < 3 then
return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height)
end
-- Extract border pixels (0-based indexing, but we convert to 1-based for processing)
local topBorder = ImageDataReader.getRow(imageData, 0)
local leftBorder = ImageDataReader.getColumn(imageData, 0)
local bottomBorder = ImageDataReader.getRow(imageData, height - 1)
local rightBorder = ImageDataReader.getColumn(imageData, width - 1)
-- Remove corner pixels from borders (they're not part of the stretch/content markers)
-- Top and bottom borders: remove first and last pixel
local topStretchPixels = {}
local bottomContentPixels = {}
for i = 2, #topBorder - 1 do
table.insert(topStretchPixels, topBorder[i])
end
for i = 2, #bottomBorder - 1 do
table.insert(bottomContentPixels, bottomBorder[i])
end
-- Left and right borders: remove first and last pixel
local leftStretchPixels = {}
local rightContentPixels = {}
for i = 2, #leftBorder - 1 do
table.insert(leftStretchPixels, leftBorder[i])
end
for i = 2, #rightBorder - 1 do
table.insert(rightContentPixels, rightBorder[i])
end
-- Find stretch regions (top and left borders)
local stretchX = findBlackPixelRuns(topStretchPixels)
local stretchY = findBlackPixelRuns(leftStretchPixels)
-- Find content padding regions (bottom and right borders)
local contentX = findBlackPixelRuns(bottomContentPixels)
local contentY = findBlackPixelRuns(rightContentPixels)
-- Validate that we have at least one stretch region
if #stretchX == 0 or #stretchY == 0 then
return nil, "No stretch regions found (top or left border has no black pixels)"
end
-- Calculate stretch insets from stretch regions (top/left guides)
-- Use the first stretch region's start and last stretch region's end
local firstStretchX = stretchX[1]
local lastStretchX = stretchX[#stretchX]
local firstStretchY = stretchY[1]
local lastStretchY = stretchY[#stretchY]
-- Stretch insets define the 9-patch regions
local stretchLeft = firstStretchX.start
local stretchRight = #topStretchPixels - lastStretchX["end"]
local stretchTop = firstStretchY.start
local stretchBottom = #leftStretchPixels - lastStretchY["end"]
-- Calculate content padding from content guides (bottom/right guides)
-- If content padding is defined, use it; otherwise use stretch regions
local contentLeft, contentRight, contentTop, contentBottom
if #contentX > 0 then
contentLeft = contentX[1].start
contentRight = #topStretchPixels - contentX[#contentX]["end"]
else
contentLeft = stretchLeft
contentRight = stretchRight
end
if #contentY > 0 then
contentTop = contentY[1].start
contentBottom = #leftStretchPixels - contentY[#contentY]["end"]
else
contentTop = stretchTop
contentBottom = stretchBottom
end
return {
insets = {
left = stretchLeft,
top = stretchTop,
right = stretchRight,
bottom = stretchBottom,
},
contentPadding = {
left = contentLeft,
top = contentTop,
right = contentRight,
bottom = contentBottom,
},
stretchX = stretchX,
stretchY = stretchY,
}
end
return NinePatchParser

View File

@@ -17,21 +17,26 @@
---@field backdropBlur {intensity:number, quality:number}?
---@field _blurInstance table?
---@field _element Element?
---@field _Color table
---@field _Color Color
---@field _RoundedRect table
---@field _NinePatch table
---@field _ImageRenderer table
---@field _ImageCache table
---@field _Theme table
---@field _Blur table
---@field _Transform Transform
---@field _Blur Blur
---@field _utils table
---@field _FONT_CACHE table
---@field _TextAlign table
---@field _ErrorHandler ErrorHandler
local Renderer = {}
Renderer.__index = Renderer
-- Lazy-loaded ErrorHandler
local ErrorHandler
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Renderer.init(deps)
Renderer._ErrorHandler = deps.ErrorHandler
end
--- Create a new Renderer instance
---@param config table Configuration table with rendering properties
@@ -198,30 +203,19 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
end)
if not success then
-- Lazy-load ErrorHandler if needed
if not ErrorHandler then
ErrorHandler = require("modules.ErrorHandler")
end
-- Check if it's a stencil buffer error
if err and err:match("stencil") then
ErrorHandler.warn(
"Renderer",
"IMG_001",
"Cannot apply corner radius to image: stencil buffer not available",
{
imagePath = self.imagePath or "unknown",
cornerRadius = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight
),
error = tostring(err),
},
"Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images"
)
Renderer._ErrorHandler:warn("Renderer", "IMG_001", "Cannot apply corner radius to image: stencil buffer not available", {
imagePath = self.imagePath or "unknown",
cornerRadius = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight
),
error = tostring(err),
}, "Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images")
-- Continue without corner radius
hasCornerRadius = false
else
@@ -368,12 +362,10 @@ function Renderer:draw(backdropCanvas)
-- Element must be initialized before drawing
if not self._element then
if not ErrorHandler then
ErrorHandler = require("modules.ErrorHandler")
end
ErrorHandler.error("Renderer", "SYS_002", "Method called before initialization", {
method = "draw"
Renderer._ErrorHandler:warn("Renderer", "SYS_002", "Method called before initialization", {
method = "draw",
}, "Call renderer:initialize(element) before rendering")
return
end
local element = self._element
@@ -421,7 +413,7 @@ function Renderer:draw(backdropCanvas)
if hasTransform then
self._Transform.unapply()
end
-- Stop performance timing
if Performance and Performance.isEnabled() and elementId then
Performance.stopTimer("render_" .. elementId)

View File

@@ -1,13 +1,3 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local NinePatchParser = req("NinePatchParser")
local Color = req("Color")
local utils = req("utils")
local ErrorHandler = req("ErrorHandler")
--- Auto-detect the base path where FlexLove is located
---@return string modulePath, string filesystemPath
local function getFlexLoveBasePath()
@@ -77,6 +67,242 @@ local function validateThemeDefinition(definition)
return true, nil
end
--- Load image data from a file path
---@param imagePath string
---@return love.ImageData
local function loadImageData(imagePath)
if not imagePath then
error("Image path cannot be nil")
end
local success, result = pcall(function()
return love.image.newImageData(imagePath)
end)
if not success then
error("Failed to load image data from '" .. imagePath .. "': " .. tostring(result))
end
return result
end
--- Extract all pixels from a specific row
---@param imageData love.ImageData
---@param rowIndex number 0-based row index
---@return table Array of {r, g, b, a} values (0-255 range)
local function getRow(imageData, rowIndex)
if not imageData then
error("ImageData cannot be nil")
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if rowIndex < 0 or rowIndex >= height then
error(string.format("Row index %d out of bounds (height: %d)", rowIndex, height))
end
local pixels = {}
for x = 0, width - 1 do
local r, g, b, a = imageData:getPixel(x, rowIndex)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Extract all pixels from a specific column
---@param imageData love.ImageData
---@param colIndex number 0-based column index
---@return table Array of {r, g, b, a} values (0-255 range)
local function getColumn(imageData, colIndex)
if not imageData then
error("ImageData cannot be nil")
end
local width = imageData:getWidth()
local height = imageData:getHeight()
if colIndex < 0 or colIndex >= width then
error(string.format("Column index %d out of bounds (width: %d)", colIndex, width))
end
local pixels = {}
for y = 0, height - 1 do
local r, g, b, a = imageData:getPixel(colIndex, y)
table.insert(pixels, {
r = math.floor(r * 255 + 0.5),
g = math.floor(g * 255 + 0.5),
b = math.floor(b * 255 + 0.5),
a = math.floor(a * 255 + 0.5),
})
end
return pixels
end
--- Check if a pixel is black with full alpha (9-patch marker)
---@param r number Red (0-255)
---@param g number Green (0-255)
---@param b number Blue (0-255)
---@param a number Alpha (0-255)
---@return boolean
local function isBlackPixel(r, g, b, a)
return r == 0 and g == 0 and b == 0 and a == 255
end
--- Find all continuous runs of black pixels in a pixel array
---@param pixels table Array of {r, g, b, a} pixel values
---@return table Array of {start, end} pairs (1-based indices, inclusive)
local function findBlackPixelRuns(pixels)
local runs = {}
local inRun = false
local runStart = nil
for i = 1, #pixels do
local pixel = pixels[i]
local isBlack = isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a)
if isBlack and not inRun then
-- Start of a new run
inRun = true
runStart = i
elseif not isBlack and inRun then
-- End of current run
table.insert(runs, { start = runStart, ["end"] = i - 1 })
inRun = false
runStart = nil
end
end
-- Handle case where run extends to end of array
if inRun then
table.insert(runs, { start = runStart, ["end"] = #pixels })
end
return runs
end
--- Parse a 9-patch PNG image to extract stretch regions and content padding
---@param imagePath string Path to the 9-patch image file
---@return table|nil, string|nil Returns {insets, stretchX, stretchY} or nil, error message
local function parseNinePatch(imagePath)
if not imagePath then
return nil, "Image path cannot be nil"
end
local success, imageData = pcall(function()
return loadImageData(imagePath)
end)
if not success then
return nil, "Failed to load image data: " .. tostring(imageData)
end
local width = imageData:getWidth()
local height = imageData:getHeight()
-- Validate minimum size (must be at least 3x3 with 1px border)
if width < 3 or height < 3 then
return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height)
end
-- Extract border pixels (0-based indexing, but we convert to 1-based for processing)
local topBorder = getRow(imageData, 0)
local leftBorder = getColumn(imageData, 0)
local bottomBorder = getRow(imageData, height - 1)
local rightBorder = getColumn(imageData, width - 1)
-- Remove corner pixels from borders (they're not part of the stretch/content markers)
-- Top and bottom borders: remove first and last pixel
local topStretchPixels = {}
local bottomContentPixels = {}
for i = 2, #topBorder - 1 do
table.insert(topStretchPixels, topBorder[i])
end
for i = 2, #bottomBorder - 1 do
table.insert(bottomContentPixels, bottomBorder[i])
end
-- Left and right borders: remove first and last pixel
local leftStretchPixels = {}
local rightContentPixels = {}
for i = 2, #leftBorder - 1 do
table.insert(leftStretchPixels, leftBorder[i])
end
for i = 2, #rightBorder - 1 do
table.insert(rightContentPixels, rightBorder[i])
end
-- Find stretch regions (top and left borders)
local stretchX = findBlackPixelRuns(topStretchPixels)
local stretchY = findBlackPixelRuns(leftStretchPixels)
-- Find content padding regions (bottom and right borders)
local contentX = findBlackPixelRuns(bottomContentPixels)
local contentY = findBlackPixelRuns(rightContentPixels)
-- Validate that we have at least one stretch region
if #stretchX == 0 or #stretchY == 0 then
return nil, "No stretch regions found (top or left border has no black pixels)"
end
-- Calculate stretch insets from stretch regions (top/left guides)
-- Use the first stretch region's start and last stretch region's end
local firstStretchX = stretchX[1]
local lastStretchX = stretchX[#stretchX]
local firstStretchY = stretchY[1]
local lastStretchY = stretchY[#stretchY]
-- Stretch insets define the 9-patch regions
local stretchLeft = firstStretchX.start
local stretchRight = #topStretchPixels - lastStretchX["end"]
local stretchTop = firstStretchY.start
local stretchBottom = #leftStretchPixels - lastStretchY["end"]
-- Calculate content padding from content guides (bottom/right guides)
-- If content padding is defined, use it; otherwise use stretch regions
local contentLeft, contentRight, contentTop, contentBottom
if #contentX > 0 then
contentLeft = contentX[1].start
contentRight = #topStretchPixels - contentX[#contentX]["end"]
else
contentLeft = stretchLeft
contentRight = stretchRight
end
if #contentY > 0 then
contentTop = contentY[1].start
contentBottom = #leftStretchPixels - contentY[#contentY]["end"]
else
contentTop = stretchTop
contentBottom = stretchBottom
end
return {
insets = {
left = stretchLeft,
top = stretchTop,
right = stretchRight,
bottom = stretchBottom,
},
contentPadding = {
left = contentLeft,
top = contentTop,
right = contentRight,
bottom = contentBottom,
},
stretchX = stretchX,
stretchY = stretchY,
}
end
---@class ThemeRegion
---@field x number -- X position in atlas
---@field y number -- Y position in atlas
@@ -117,9 +343,22 @@ end
---@field colors table<string, Color>
---@field fonts table<string, string> -- Font family definitions
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions
---@field _ErrorHandler table? ErrorHandler module dependency
---@field _Color table? Color module dependency
---@field _utils table? utils module dependency
local Theme = {}
Theme.__index = Theme
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler, Color, utils}
function Theme.init(deps)
if type(deps) == "table" then
Theme._ErrorHandler = deps.ErrorHandler
Theme._Color = deps.Color
Theme._utils = deps.utils
end
end
-- Global theme registry
local themes = {}
local activeTheme = nil
@@ -131,17 +370,19 @@ local activeTheme = nil
function Theme.new(definition)
-- Validate input type first
if type(definition) ~= "table" then
ErrorHandler.error("Theme", "THM_001", "Invalid theme definition", {
error = "Theme definition must be a table, got " .. type(definition)
Theme._ErrorHandler:warn("Theme", "THM_001", "Invalid theme definition", {
error = "Theme definition must be a table, got " .. type(definition),
})
return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} })
end
-- Validate theme definition
local valid, err = validateThemeDefinition(definition)
if not valid then
ErrorHandler.error("Theme", "THM_001", "Invalid theme definition", {
error = tostring(err)
Theme._ErrorHandler:warn("Theme", "THM_001", "Invalid theme definition", {
error = tostring(err),
})
return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} })
end
local self = setmetatable({}, Theme)
@@ -150,16 +391,16 @@ function Theme.new(definition)
-- Load global atlas if it's a string path
if definition.atlas then
if type(definition.atlas) == "string" then
local resolvedPath = utils.resolveImagePath(definition.atlas)
local image, imageData, loaderr = utils.safeLoadImage(resolvedPath)
local resolvedPath = Theme._utils.resolveImagePath(definition.atlas)
local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath)
if image then
self.atlas = image
self.atlasData = imageData
else
ErrorHandler.warn("Theme", "RES_001", "Failed to load global atlas", {
Theme._ErrorHandler:warn("Theme", "RES_001", "Failed to load global atlas", {
theme = definition.name,
path = resolvedPath,
error = loaderr
error = loaderr,
})
end
else
@@ -184,11 +425,12 @@ function Theme.new(definition)
local contentHeight = srcHeight - 2
if contentWidth <= 0 or contentHeight <= 0 then
ErrorHandler.error("Theme", "RES_002", "Nine-patch image too small", {
Theme._ErrorHandler:warn("Theme", "RES_002", "Nine-patch image too small", {
width = srcWidth,
height = srcHeight,
reason = "Image must be larger than 2x2 pixels to have content after stripping 1px border"
reason = "Image must be larger than 2x2 pixels to have content after stripping 1px border",
})
return nil
end
-- Create new ImageData for content only
@@ -208,25 +450,25 @@ function Theme.new(definition)
-- Helper function to load atlas with 9-patch support
local function loadAtlasWithNinePatch(comp, atlasPath, errorContext)
---@diagnostic disable-next-line
local resolvedPath = utils.resolveImagePath(atlasPath)
local resolvedPath = Theme._utils.resolveImagePath(atlasPath)
---@diagnostic disable-next-line
local is9Patch = not comp.insets and atlasPath:match("%.9%.png$")
if is9Patch then
local parseResult, parseErr = NinePatchParser.parse(resolvedPath)
local parseResult, parseErr = parseNinePatch(resolvedPath)
if parseResult then
comp.insets = parseResult.insets
comp._ninePatchData = parseResult
else
ErrorHandler.warn("Theme", "RES_003", "Failed to parse nine-patch image", {
Theme._ErrorHandler:warn("Theme", "RES_003", "Failed to parse nine-patch image", {
context = errorContext,
path = resolvedPath,
error = tostring(parseErr)
error = tostring(parseErr),
})
end
end
local image, imageData, loaderr = utils.safeLoadImage(resolvedPath)
local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath)
if image then
-- Strip guide border for 9-patch images
if is9Patch and imageData then
@@ -239,10 +481,10 @@ function Theme.new(definition)
comp._loadedAtlasData = imageData
end
else
ErrorHandler.warn("Theme", "RES_001", "Failed to load atlas", {
Theme._ErrorHandler:warn("Theme", "RES_001", "Failed to load atlas", {
context = errorContext,
path = resolvedPath,
error = tostring(loaderr)
error = tostring(loaderr),
})
end
end
@@ -333,11 +575,11 @@ function Theme.load(path)
if success then
definition = result
else
ErrorHandler.warn("Theme", "RES_004", "Failed to load theme file", {
Theme._ErrorHandler:warn("Theme", "RES_004", "Failed to load theme file", {
theme = path,
tried = themePath,
error = tostring(result),
fallback = "nil (no theme loaded)"
fallback = "nil (no theme loaded)",
}, "Check that the theme file exists in the themes/ directory or provide a valid module path")
return nil
end
@@ -365,10 +607,10 @@ function Theme.setActive(themeOrName)
end
if not activeTheme then
ErrorHandler.warn("Theme", "THM_002", "Failed to set active theme", {
Theme._ErrorHandler:warn("Theme", "THM_002", "Failed to set active theme", {
theme = tostring(themeOrName),
reason = "Theme not found or not loaded",
fallback = "current theme unchanged"
fallback = "current theme unchanged",
}, "Ensure the theme is loaded with Theme.load() before setting it active")
-- Keep current activeTheme unchanged (fallback behavior)
end
@@ -479,7 +721,7 @@ function Theme.getColorOrDefault(colorName, fallback)
return color
end
return fallback or Color.new(1, 1, 1, 1)
return fallback or Theme._Color.new(1, 1, 1, 1)
end
--- Get a theme by name
@@ -596,7 +838,7 @@ function ThemeManager:getComponent()
if not themeToUse or not themeToUse.components or type(themeToUse.components) ~= "table" then
return nil
end
if not themeToUse.components[self.themeComponent] then
return nil
end
@@ -627,7 +869,7 @@ function ThemeManager:getStyle(property)
if type(property) ~= "string" then
return nil
end
local stateComponent = self:getStateComponent()
if not stateComponent or type(stateComponent) ~= "table" then
return nil
@@ -776,7 +1018,7 @@ function Theme.validateTheme(theme, options)
end
elseif colorType == "string" then
-- Validate color string
local isValid, err = Color.validateColor(colorValue)
local isValid, err = Theme._Color.validateColor(colorValue)
if not isValid then
table.insert(errors, "Color '" .. colorName .. "': " .. err)
end
@@ -952,12 +1194,12 @@ function Theme.sanitizeTheme(theme)
sanitized.colors[colorName] = colorValue
elseif colorType == "string" then
-- Try to validate color string
local isValid = Color.validateColor(colorValue)
local isValid = Theme._Color.validateColor(colorValue)
if isValid then
sanitized.colors[colorName] = colorValue
else
-- Provide fallback color
sanitized.colors[colorName] = Color.new(0, 0, 0, 1)
sanitized.colors[colorName] = Theme._Color.new(0, 0, 0, 1)
end
end
end

View File

@@ -1,93 +1,70 @@
--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh, ew, eh)
--- Provides unit parsing, validation, and conversion to pixel values
---@class Units
---@field _Context table? Context module dependency
---@field _ErrorHandler table? ErrorHandler module dependency
local Units = {}
local Context = nil
local ErrorHandler = nil
--- Initialize Units module with Context dependency
---@param context table The Context module
--- Initialize dependencies
---@param deps table Dependencies: { Context = Context?, ErrorHandler = ErrorHandler? }
--- Initialize Units module with dependencies
---@param deps table Dependencies: { Context = table?, ErrorHandler = table? }
function Units.init(deps)
if type(deps) == "table" then
if deps.Context then
Context = deps.Context
end
if deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
Units._Context = deps.Context
Units._ErrorHandler = deps.ErrorHandler
end
---@param value string|number
---@return number, string -- Returns numeric value and unit type ("px", "%", "vw", "vh")
--- Parse a unit value into numeric value and unit type
--- Supports: px (pixels), % (percentage), vw/vh (viewport), ew/eh (element)
---@param value string|number The value to parse (e.g., "50px", "10%", "2vw", 100)
---@return number numericValue The numeric portion of the value
---@return string unitType The unit type ("px", "%", "vw", "vh", "ew", "eh")
function Units.parse(value)
if type(value) == "number" then
return value, "px"
end
if type(value) ~= "string" then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_001", "Invalid property type", {
property = "unit value",
expected = "string or number",
got = type(value)
}, "Using fallback: 0px")
else
print(string.format("[FlexLove - Units] Warning: Invalid unit value type. Expected string or number, got %s. Using fallback: 0px", type(value)))
end
Units._ErrorHandler:warn("Units", "VAL_001", "Invalid property type", {
property = "unit value",
expected = "string or number",
got = type(value),
}, "Using fallback: 0px")
return 0, "px"
end
-- Check for unit-only input (e.g., "px", "%", "vw" without a number)
local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true }
if validUnits[value] then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_005", "Invalid unit format", {
input = value,
expected = "number + unit (e.g., '50" .. value .. "')"
}, string.format("Add a numeric value before '%s', like '50%s'. Using fallback: 0px", value, value))
else
print(string.format("[FlexLove - Units] Warning: Missing numeric value before unit '%s'. Use format like '50%s'. Using fallback: 0px", value, value))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
input = value,
expected = "number + unit (e.g., '50" .. value .. "')",
}, string.format("Add a numeric value before '%s', like '50%s'. Using fallback: 0px", value, value))
return 0, "px"
end
-- Check for invalid format (space between number and unit)
if value:match("%d%s+%a") then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_005", "Invalid unit format", {
input = value,
issue = "contains space between number and unit"
}, "Remove spaces: use '50px' not '50 px'. Using fallback: 0px")
else
print(string.format("[FlexLove - Units] Warning: Invalid unit string '%s' (contains space). Use format like '50px' or '50%%'. Using fallback: 0px", value))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
input = value,
issue = "contains space between number and unit",
}, "Remove spaces: use '50px' not '50 px'. Using fallback: 0px")
return 0, "px"
end
-- Match number followed by optional unit
local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$")
if not numStr then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_005", "Invalid unit format", {
input = value
}, "Expected format: number + unit (e.g., '50px', '10%', '2vw'). Using fallback: 0px")
else
print(string.format("[FlexLove - Units] Warning: Invalid unit format '%s'. Expected format: number + unit (e.g., '50px', '10%%', '2vw'). Using fallback: 0px", value))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
input = value,
}, "Expected format: number + unit (e.g., '50px', '10%', '2vw'). Using fallback: 0px")
return 0, "px"
end
local num = tonumber(numStr)
if not num then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_005", "Invalid unit format", {
input = value,
issue = "numeric value cannot be parsed"
}, "Using fallback: 0px")
else
print(string.format("[FlexLove - Units] Warning: Invalid numeric value in '%s'. Using fallback: 0px", value))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
input = value,
issue = "numeric value cannot be parsed",
}, "Using fallback: 0px")
return 0, "px"
end
@@ -98,42 +75,35 @@ function Units.parse(value)
-- validUnits is already defined at the top of the function
if not validUnits[unit] then
if ErrorHandler then
ErrorHandler.warn("Units", "VAL_005", "Invalid unit format", {
input = value,
unit = unit,
validUnits = "px, %, vw, vh, ew, eh"
}, string.format("Treating '%s' as pixels", value))
else
print(string.format("[FlexLove - Units] Warning: Unknown unit '%s' in '%s'. Valid units: px, %%, vw, vh, ew, eh. Treating as pixels", unit, value))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
input = value,
unit = unit,
validUnits = "px, %, vw, vh, ew, eh",
}, string.format("Treating '%s' as pixels", value))
return num, "px"
end
return num, unit
end
--- Convert relative units to pixels based on viewport and parent dimensions
---@param value number -- Numeric value to convert
---@param unit string -- Unit type ("px", "%", "vw", "vh", "ew", "eh")
---@param viewportWidth number -- Current viewport width in pixels
---@param viewportHeight number -- Current viewport height in pixels
---@param parentSize number? -- Required for percentage units (parent dimension)
---@return number -- Resolved pixel value
---@throws Error if unit type is unknown or percentage used without parent size
--- Convert relative units to absolute pixel values
--- Resolves %, vw, vh units based on viewport and parent dimensions
---@param value number Numeric value to convert
---@param unit string Unit type ("px", "%", "vw", "vh", "ew", "eh")
---@param viewportWidth number Current viewport width in pixels
---@param viewportHeight number Current viewport height in pixels
---@param parentSize number? Required for percentage units (parent dimension in pixels)
---@return number resolvedValue Resolved pixel value
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
if ErrorHandler then
ErrorHandler.error("Units", "LAY_003", "Invalid dimensions", {
unit = "%",
issue = "parent dimension not available"
}, "Percentage units require a parent element with explicit dimensions")
else
error("Percentage units require parent dimension")
end
Units._ErrorHandler:warn("Units", "LAY_003", "Invalid dimensions", {
unit = "%",
issue = "parent dimension not available",
}, "Percentage units require a parent element with explicit dimensions. Using fallback: 0px")
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
@@ -141,22 +111,22 @@ function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
if ErrorHandler then
ErrorHandler.error("Units", "VAL_005", "Invalid unit format", {
unit = unit,
validUnits = "px, %, vw, vh, ew, eh"
})
else
error(string.format("Unknown unit type: '%s'", unit))
end
Units._ErrorHandler:warn("Units", "VAL_005", "Invalid unit format", {
unit = unit,
validUnits = "px, %, vw, vh, ew, eh",
}, string.format("Unknown unit type: '%s'. Using fallback: 0px", unit))
return 0
end
end
---@return number, number -- width, height
--- Get current viewport dimensions
--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics
---@return number width Viewport width in pixels
---@return number height Viewport height in pixels
function Units.getViewport()
-- Return cached viewport if available (only during resize operations)
if Context and Context._cachedViewport and Context._cachedViewport.width > 0 then
return Context._cachedViewport.width, Context._cachedViewport.height
if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then
return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height
end
if love.graphics and love.graphics.getDimensions then
@@ -167,10 +137,12 @@ function Units.getViewport()
end
end
---@param value number
---@param axis "x"|"y"
---@param scaleFactors {x:number, y:number}
---@return number
--- Apply base scale factor to a value based on axis
--- Used for responsive scaling of UI elements
---@param value number The value to scale
---@param axis "x"|"y" The axis to scale on
---@param scaleFactors {x:number, y:number} Scale factors for each axis
---@return number scaledValue The scaled value
function Units.applyBaseScale(value, axis, scaleFactors)
if axis == "x" then
return value * scaleFactors.x
@@ -179,10 +151,12 @@ function Units.applyBaseScale(value, axis, scaleFactors)
end
end
---@param spacingProps table?
---@param parentWidth number
---@param parentHeight number
---@return table -- Resolved spacing with top, right, bottom, left in pixels
--- Resolve spacing properties (margin, padding) to pixel values
--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal)
---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal
---@param parentWidth number Parent element width in pixels
---@param parentHeight number Parent element height in pixels
---@return table resolvedSpacing Table with top, right, bottom, left in pixels
function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
if not spacingProps then
return { top = 0, right = 0, bottom = 0, left = 0 }
@@ -230,8 +204,10 @@ function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
return result
end
---@param unitStr string
---@return boolean
--- Validate a unit string format
--- Checks if the string can be successfully parsed as a valid unit
---@param unitStr string The unit string to validate (e.g., "50px", "10%")
---@return boolean isValid True if the unit string is valid, false otherwise
function Units.isValid(unitStr)
if type(unitStr) ~= "string" then
return false
@@ -264,14 +240,4 @@ function Units.isValid(unitStr)
return validUnits[unit] == true
end
---@param value string|number -- Value to parse and resolve
---@param viewportWidth number -- Current viewport width
---@param viewportHeight number -- Current viewport height
---@param parentSize number? -- Parent dimension for percentage units
---@return number -- Resolved pixel value
function Units.parseAndResolve(value, viewportWidth, viewportHeight, parentSize)
local numValue, unit = Units.parse(value)
return Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
end
return Units

View File

@@ -161,31 +161,36 @@ function TestUnitsResolve:testResolveDecimalPercentage()
luaunit.assertAlmostEquals(result, 99.99, 0.01)
end
-- Test suite for Units.parseAndResolve()
-- Test suite for Units.parse() + Units.resolve() combination
TestUnitsParseAndResolve = {}
function TestUnitsParseAndResolve:testParseAndResolvePixels()
local result = Units.parseAndResolve("100px", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
local numValue, unit = Units.parse("100px")
local result = Units.resolve(numValue, unit, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
luaunit.assertEquals(result, 100)
end
function TestUnitsParseAndResolve:testParseAndResolveNumber()
local result = Units.parseAndResolve(100, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
local numValue, unit = Units.parse(100)
local result = Units.resolve(numValue, unit, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
luaunit.assertEquals(result, 100)
end
function TestUnitsParseAndResolve:testParseAndResolvePercentage()
local result = Units.parseAndResolve("50%", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 400)
local numValue, unit = Units.parse("50%")
local result = Units.resolve(numValue, unit, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT, 400)
luaunit.assertEquals(result, 200)
end
function TestUnitsParseAndResolve:testParseAndResolveViewportWidth()
local result = Units.parseAndResolve("10vw", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
local numValue, unit = Units.parse("10vw")
local result = Units.resolve(numValue, unit, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
luaunit.assertEquals(result, 192)
end
function TestUnitsParseAndResolve:testParseAndResolveViewportHeight()
local result = Units.parseAndResolve("50vh", MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
local numValue, unit = Units.parse("50vh")
local result = Units.resolve(numValue, unit, MOCK_VIEWPORT_WIDTH, MOCK_VIEWPORT_HEIGHT)
luaunit.assertEquals(result, 540)
end