diff --git a/FlexLove.lua b/FlexLove.lua index 8684e7f..3cc94a3 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -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 diff --git a/modules/Color.lua b/modules/Color.lua index 2ad30fe..0c393c4 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -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 diff --git a/modules/Element.lua b/modules/Element.lua index ae45302..534a58a 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -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), diff --git a/modules/ImageDataReader.lua b/modules/ImageDataReader.lua deleted file mode 100644 index d22d571..0000000 --- a/modules/ImageDataReader.lua +++ /dev/null @@ -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 diff --git a/modules/NinePatchParser.lua b/modules/NinePatchParser.lua deleted file mode 100644 index 31bf4db..0000000 --- a/modules/NinePatchParser.lua +++ /dev/null @@ -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 diff --git a/modules/Renderer.lua b/modules/Renderer.lua index b433d16..d591e1a 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -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) diff --git a/modules/Theme.lua b/modules/Theme.lua index 22afa61..24741dd 100644 --- a/modules/Theme.lua +++ b/modules/Theme.lua @@ -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 ---@field fonts table -- 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 diff --git a/modules/Units.lua b/modules/Units.lua index e8a42b8..f504e10 100644 --- a/modules/Units.lua +++ b/modules/Units.lua @@ -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 diff --git a/testing/__tests__/units_test.lua b/testing/__tests__/units_test.lua index 4dc74cc..90791db 100644 --- a/testing/__tests__/units_test.lua +++ b/testing/__tests__/units_test.lua @@ -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