begin major Element refactor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user