--- Auto-detect the base path where FlexLove is located ---@return string modulePath, string filesystemPath local function getFlexLoveBasePath() -- Get debug info to find where this file is loaded from local info = debug.getinfo(1, "S") if info and info.source then local source = info.source -- Remove leading @ if present if source:sub(1, 1) == "@" then source = source:sub(2) end -- Extract the directory path (remove Theme.lua and modules/) local filesystemPath = source:match("(.*/)") if filesystemPath then -- Store the original filesystem path for loading assets local fsPath = filesystemPath -- Remove leading ./ if present fsPath = fsPath:gsub("^%./", "") -- Remove trailing / fsPath = fsPath:gsub("/$", "") -- Remove the flexlove subdirectory to get back to base fsPath = fsPath:gsub("/modules$", "") -- Convert filesystem path to Lua module path local modulePath = fsPath:gsub("/", ".") return modulePath, fsPath end end -- Fallback: try a common path return "libs", "libs" end -- Store the base paths when module loads local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() --- Validate theme definition structure ---@param definition ThemeDefinition ---@return boolean, string? -- Returns true if valid, or false with error message local function validateThemeDefinition(definition) if not definition then return false, "Theme definition is nil" end if type(definition) ~= "table" then return false, "Theme definition must be a table" end if not definition.name or type(definition.name) ~= "string" then return false, "Theme must have a 'name' field (string)" end if definition.components and type(definition.components) ~= "table" then return false, "Theme 'components' must be a table" end if definition.colors and type(definition.colors) ~= "table" then return false, "Theme 'colors' must be a table" end if definition.fonts and type(definition.fonts) ~= "table" then return false, "Theme 'fonts' must be a table" end 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 ---@field w number -- Width in atlas ---@field h number -- Height in atlas ---@class ThemeComponent ---@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas). Files ending in .9.png are auto-parsed ---@field insets {left:number, top:number, right:number, bottom:number}? -- Optional: 9-patch insets (auto-extracted from .9.png files or manually defined) ---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion} ---@field stretch {horizontal:table, vertical:table} ---@field states table? ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions ---@field scaleCorners number? -- Optional: scale multiplier for non-stretched regions (corners/edges). E.g., 2 = 2x size. Default: nil (no scaling) ---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear" ---@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image ---@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access ---@field _ninePatchData {insets:table, contentPadding:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with stretch regions and content padding ---@field _scaledRegionCache table? -- Internal: cache for scaled corner/edge images ---@class FontFamily ---@field path string -- Path to the font file (relative to FlexLove or absolute) ---@field _loadedFont love.Font? -- Internal: cached loaded font ---@class ThemeDefinition ---@field name string ---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) ---@field components table ---@field colors table? ---@field fonts table? -- Optional: font family definitions (name -> path) ---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions ---@class Theme ---@field name string ---@field atlas love.Image? -- Optional: global atlas ---@field atlasData love.ImageData? ---@field components table ---@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 --- Create reusable design systems with consistent styling, 9-patch assets, and component states --- Use this to build professional-looking UIs with minimal per-element configuration ---@param definition ThemeDefinition Theme definition table ---@return Theme theme The new theme instance function Theme.new(definition) -- Validate input type first if type(definition) ~= "table" then 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 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) self.name = definition.name -- Load global atlas if it's a string path if definition.atlas then if type(definition.atlas) == "string" then 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 Theme._ErrorHandler:warn("Theme", "RES_001", "Failed to load global atlas", { theme = definition.name, path = resolvedPath, error = loaderr, }) end else self.atlas = definition.atlas end end self.components = definition.components or {} self.colors = definition.colors or {} self.fonts = definition.fonts or {} self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil -- Helper function to strip 1-pixel guide border from 9-patch ImageData ---@param sourceImageData love.ImageData ---@return love.ImageData -- New ImageData without guide border local function stripNinePatchBorder(sourceImageData) local srcWidth = sourceImageData:getWidth() local srcHeight = sourceImageData:getHeight() -- Content dimensions (excluding 1px border on all sides) local contentWidth = srcWidth - 2 local contentHeight = srcHeight - 2 if contentWidth <= 0 or contentHeight <= 0 then 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", }) return nil end -- Create new ImageData for content only local strippedImageData = love.image.newImageData(contentWidth, contentHeight) -- Copy pixels from source (1,1) to (width-2, height-2) for y = 0, contentHeight - 1 do for x = 0, contentWidth - 1 do local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1) strippedImageData:setPixel(x, y, r, g, b, a) end end return strippedImageData end -- Helper function to load atlas with 9-patch support local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) ---@diagnostic disable-next-line 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 = parseNinePatch(resolvedPath) if parseResult then comp.insets = parseResult.insets comp._ninePatchData = parseResult else Theme._ErrorHandler:warn("Theme", "RES_003", "Failed to parse nine-patch image", { context = errorContext, path = resolvedPath, error = tostring(parseErr), }) end end local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath) if image then -- Strip guide border for 9-patch images if is9Patch and imageData then local strippedImageData = stripNinePatchBorder(imageData) local strippedImage = love.graphics.newImage(strippedImageData) comp._loadedAtlas = strippedImage comp._loadedAtlasData = strippedImageData else comp._loadedAtlas = image comp._loadedAtlasData = imageData end else Theme._ErrorHandler:warn("Theme", "RES_001", "Failed to load atlas", { context = errorContext, path = resolvedPath, error = tostring(loaderr), }) end end -- Helper function to create regions from insets local function createRegionsFromInsets(comp, fallbackAtlas) local atlasImage = comp._loadedAtlas or fallbackAtlas if not atlasImage or type(atlasImage) == "string" then return end local imgWidth, imgHeight = atlasImage:getDimensions() local left = comp.insets.left or 0 local top = comp.insets.top or 0 local right = comp.insets.right or 0 local bottom = comp.insets.bottom or 0 -- No offsets needed - guide border has been stripped for 9-patch images local centerWidth = imgWidth - left - right local centerHeight = imgHeight - top - bottom comp.regions = { topLeft = { x = 0, y = 0, w = left, h = top }, topCenter = { x = left, y = 0, w = centerWidth, h = top }, topRight = { x = left + centerWidth, y = 0, w = right, h = top }, middleLeft = { x = 0, y = top, w = left, h = centerHeight }, middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight }, middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight }, bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom }, bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom }, bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom }, } end -- Load component-specific atlases and process 9-patch definitions for componentName, component in pairs(self.components) do if component.atlas then if type(component.atlas) == "string" then loadAtlasWithNinePatch(component, component.atlas, "for component '" .. componentName .. "'") else -- Direct Image object (no ImageData available - scaleCorners won't work) component._loadedAtlas = component.atlas end end if component.insets then createRegionsFromInsets(component, self.atlas) end if component.states then for stateName, stateComponent in pairs(component.states) do if stateComponent.atlas then if type(stateComponent.atlas) == "string" then loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for state '" .. stateName .. "'") else -- Direct Image object (no ImageData available - scaleCorners won't work) stateComponent._loadedAtlas = stateComponent.atlas end end if stateComponent.insets then createRegionsFromInsets(stateComponent, component._loadedAtlas or self.atlas) end end end end return self end --- Import a theme definition from a file to enable hot-reloading and modular design systems --- Use this to load bundled or user-created themes dynamically ---@param path string Path to theme definition file (e.g., "space" or "mytheme") ---@return Theme? theme The loaded theme, or nil on error function Theme.load(path) local definition local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path local success, result = pcall(function() return require(themePath) end) if success then definition = result else success, result = pcall(function() return require(path) end) if success then definition = result else Theme._ErrorHandler:warn("Theme", "RES_004", "Failed to load theme file", { theme = path, tried = themePath, error = tostring(result), fallback = "nil (no theme loaded)", }, "Check that the theme file exists in the themes/ directory or provide a valid module path") return nil end end local theme = Theme.new(definition) themes[theme.name] = theme themes[path] = theme return theme end --- Switch the global theme to instantly restyle all themed UI elements --- Use this to implement light/dark mode toggles or user-selectable skins ---@param themeOrName Theme|string Theme instance or theme name to activate function Theme.setActive(themeOrName) if type(themeOrName) == "string" then -- Try to load if not already loaded if not themes[themeOrName] then Theme.load(themeOrName) end activeTheme = themes[themeOrName] else activeTheme = themeOrName end if not activeTheme then 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", }, "Ensure the theme is loaded with Theme.load() before setting it active") -- Keep current activeTheme unchanged (fallback behavior) end end --- Access the current theme to query colors, fonts, or create theme-aware components --- Use this to build UI that adapts to the active design system ---@return Theme? theme The active theme, or nil if none is active function Theme.getActive() return activeTheme end --- Retrieve pre-configured visual styles for UI components to maintain consistency --- Use this to apply theme definitions to custom elements ---@param componentName string Name of the component (e.g., "button", "panel") ---@param state string? Optional state (e.g., "hover", "pressed", "disabled") ---@return ThemeComponent? component Returns component or nil if not found function Theme.getComponent(componentName, state) if not activeTheme then return nil end local component = activeTheme.components[componentName] if not component then return nil end -- Check for state-specific override if state and component.states and component.states[state] then return component.states[state] end return component end --- Access theme-defined fonts for consistent typography across your UI --- Use this to load fonts specified in your theme definition ---@param fontName string Name of the font family (e.g., "default", "heading") ---@return string? fontPath Returns font path or nil if not found function Theme.getFont(fontName) if not activeTheme then return nil end return activeTheme.fonts and activeTheme.fonts[fontName] end --- Retrieve semantic colors from the theme palette for consistent brand identity --- Use this instead of hardcoding colors to support themeing and color scheme switches ---@param colorName string Name of the color (e.g., "primary", "secondary") ---@return Color? color Returns Color instance or nil if not found function Theme.getColor(colorName) if not activeTheme then return nil end return activeTheme.colors and activeTheme.colors[colorName] end --- Check if a theme is currently active ---@return boolean active Returns true if a theme is active function Theme.hasActive() return activeTheme ~= nil end --- Get all registered theme names ---@return string[] themeNames Array of theme names function Theme.getRegisteredThemes() local themeNames = {} for name, _ in pairs(themes) do table.insert(themeNames, name) end return themeNames end --- Get all available color names from the active theme ---@return string[]? colorNames Array of color names, or nil if no theme active function Theme.getColorNames() if not activeTheme or not activeTheme.colors then return nil end local colorNames = {} for name, _ in pairs(activeTheme.colors) do table.insert(colorNames, name) end return colorNames end --- Get all colors from the active theme ---@return table? colors Table of all colors, or nil if no theme active function Theme.getAllColors() if not activeTheme then return nil end return activeTheme.colors end --- Safely get theme colors with guaranteed fallbacks to prevent missing color errors --- Use this when you need a color value no matter what ---@param colorName string Name of the color to retrieve ---@param fallback Color? Fallback color if not found (default: white) ---@return Color color The color or fallback (guaranteed non-nil) function Theme.getColorOrDefault(colorName, fallback) local color = Theme.getColor(colorName) if color then return color end return fallback or Theme._Color.new(1, 1, 1, 1) end --- Get a theme by name ---@param themeName string Name of the theme ---@return Theme? theme Returns theme or nil if not found function Theme.get(themeName) return themes[themeName] end -------------------------------------------------------------------------------- -- ThemeManager: Instance-level theme state management -------------------------------------------------------------------------------- ---@class ThemeManager ---@field theme string? -- Override theme name ---@field themeComponent string? -- Component to use from theme ---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled) ---@field disabled boolean ---@field active boolean ---@field disableHighlight boolean -- If true, disable pressed highlight overlay ---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges ---@field scalingAlgorithm string? -- "nearest" or "bilinear" scaling for 9-patch ---@field _element Element? -- Reference to parent Element local ThemeManager = {} ThemeManager.__index = ThemeManager ---Create a new ThemeManager instance ---@param config table Configuration options {theme: string?, themeComponent: string?, disabled: boolean?, active: boolean?, disableHighlight: boolean?, scaleCorners: number?, scalingAlgorithm: string?} ---@return ThemeManager manager The new ThemeManager instance function ThemeManager.new(config) local self = setmetatable({}, ThemeManager) self.theme = config.theme self.themeComponent = config.themeComponent self.disabled = config.disabled or false self.active = config.active or false self.disableHighlight = config.disableHighlight self.scaleCorners = config.scaleCorners self.scalingAlgorithm = config.scalingAlgorithm self._themeState = "normal" return self end ---Update the theme state based on element interaction state ---@param isHovered boolean Whether element is hovered ---@param isPressed boolean Whether element is pressed ---@param isFocused boolean Whether element is focused ---@param isDisabled boolean Whether element is disabled ---@return string state The new theme state ("normal", "hover", "pressed", "active", "disabled") function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) local newState = "normal" if isDisabled or self.disabled then newState = "disabled" elseif self.active then newState = "active" elseif isPressed then newState = "pressed" elseif isHovered then newState = "hover" end self._themeState = newState return newState end ---Get the current theme state ---@return string state The current theme state function ThemeManager:getState() return self._themeState end ---Set the theme state explicitly ---@param state string The theme state to set ("normal", "hover", "pressed", "active", "disabled") function ThemeManager:setState(state) if type(state) ~= "string" then return end self._themeState = state end ---Check if a theme component is set ---@return boolean hasComponent True if a theme component is set function ThemeManager:hasThemeComponent() return self.themeComponent ~= nil end ---Get the theme (either instance-specific or active theme) ---@return Theme? theme The theme instance, or nil if not found function ThemeManager:getTheme() if self.theme then return Theme.get(self.theme) end return Theme.getActive() end ---Get the base theme component ---@return ThemeComponent? component The theme component, or nil if not found function ThemeManager:getComponent() if not self.themeComponent then return nil end local themeToUse = self:getTheme() 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 return themeToUse.components[self.themeComponent] end ---Get the theme component for the current state ---@return ThemeComponent? component The state-specific component, or base component, or nil function ThemeManager:getStateComponent() local component = self:getComponent() if not component then return nil end local state = self._themeState if state and state ~= "normal" and component.states and type(component.states) == "table" and component.states[state] then return component.states[state] end return component end ---Get a style property from the current state component ---@param property string The property name ---@return any? value The property value, or nil if not found 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 end return stateComponent[property] end ---Get scaled content padding based on border box dimensions ---@param borderBoxWidth number The border box width ---@param borderBoxHeight number The border box height ---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) if not self.themeComponent then return nil end local themeToUse = self:getTheme() if not themeToUse or not themeToUse.components[self.themeComponent] then return nil end local component = themeToUse.components[self.themeComponent] local state = self._themeState or "normal" if state and state ~= "normal" and component.states and component.states[state] then component = component.states[state] end if not component._ninePatchData or not component._ninePatchData.contentPadding then return nil end local contentPadding = component._ninePatchData.contentPadding local atlasImage = component._loadedAtlas or themeToUse.atlas if atlasImage and type(atlasImage) ~= "string" then local originalWidth, originalHeight = atlasImage:getDimensions() local scaleX = borderBoxWidth / originalWidth local scaleY = borderBoxHeight / originalHeight return { left = contentPadding.left * scaleX, top = contentPadding.top * scaleY, right = contentPadding.right * scaleX, bottom = contentPadding.bottom * scaleY, } end return nil end ---Get content auto-sizing multiplier from theme or component ---@return table? multiplier Table with {width: number?, height: number?}, or nil if not defined function ThemeManager:getContentAutoSizingMultiplier() if not self.themeComponent then return nil end local themeToUse = self:getTheme() if not themeToUse then return nil end if self.themeComponent and themeToUse.components and type(themeToUse.components) == "table" then local component = themeToUse.components[self.themeComponent] if component and component.contentAutoSizingMultiplier then return component.contentAutoSizingMultiplier elseif themeToUse.contentAutoSizingMultiplier then return themeToUse.contentAutoSizingMultiplier end end if themeToUse.contentAutoSizingMultiplier then return themeToUse.contentAutoSizingMultiplier end return nil end ---Get the default font family path from the theme ---@return string? fontPath The default font path, or nil if not defined function ThemeManager:getDefaultFontFamily() local themeToUse = self:getTheme() if themeToUse and themeToUse.fonts and type(themeToUse.fonts) == "table" and themeToUse.fonts["default"] then return themeToUse.fonts["default"] end return nil end ---Set the theme and component for this ThemeManager ---@param themeName string? The theme name to use (nil to use active theme) ---@param componentName string? The component name to use function ThemeManager:setTheme(themeName, componentName) self.theme = themeName self.themeComponent = componentName end --- Cleanup method to break circular references (for immediate mode) function ThemeManager:_cleanup() -- ThemeManager doesn't create circular references -- Theme refs are to shared theme objects end -- Export both Theme and ThemeManager Theme.Manager = ThemeManager --- Check theme definitions for correctness before use to catch configuration errors early --- Use this during development to verify custom themes are properly structured ---@param theme table? The theme to validate ---@param options table? Optional validation options {strict: boolean} ---@return boolean valid, table errors List of validation errors function Theme.validateTheme(theme, options) local errors = {} options = options or {} -- Basic structure validation if theme == nil then table.insert(errors, "Theme is nil") return false, errors end if type(theme) ~= "table" then table.insert(errors, "Theme must be a table") return false, errors end -- Name validation (only required field) if not theme.name then table.insert(errors, "Theme must have a 'name' field") elseif type(theme.name) ~= "string" then table.insert(errors, "Theme 'name' must be a string") elseif theme.name == "" then table.insert(errors, "Theme 'name' cannot be empty") end -- Colors validation (optional, but if present must be valid) if theme.colors ~= nil then if type(theme.colors) ~= "table" then table.insert(errors, "Theme 'colors' must be a table") else for colorName, colorValue in pairs(theme.colors) do if type(colorName) ~= "string" then table.insert(errors, "Color name must be a string, got " .. type(colorName)) else -- Accept Color objects, hex strings, or named colors local colorType = type(colorValue) if colorType == "table" then -- Assume it's a Color object if it has r,g,b fields if not (colorValue.r and colorValue.g and colorValue.b) then table.insert(errors, "Color '" .. colorName .. "' is not a valid Color object") end elseif colorType == "string" then -- Validate color string local isValid, err = Theme._Color.validateColor(colorValue) if not isValid then table.insert(errors, "Color '" .. colorName .. "': " .. err) end else table.insert(errors, "Color '" .. colorName .. "' must be a Color object or string") end end end end end -- Fonts validation (optional) if theme.fonts ~= nil then if type(theme.fonts) ~= "table" then table.insert(errors, "Theme 'fonts' must be a table") else for fontName, fontPath in pairs(theme.fonts) do if type(fontName) ~= "string" then table.insert(errors, "Font name must be a string, got " .. type(fontName)) elseif type(fontPath) ~= "string" then table.insert(errors, "Font '" .. fontName .. "' path must be a string") end end end end -- Components validation (optional) if theme.components ~= nil then if type(theme.components) ~= "table" then table.insert(errors, "Theme 'components' must be a table") else for componentName, component in pairs(theme.components) do if type(component) == "table" then -- Validate atlas if present if component.atlas ~= nil and type(component.atlas) ~= "string" then table.insert(errors, "Component '" .. componentName .. "' atlas must be a string") end -- Validate insets if present if component.insets ~= nil then if type(component.insets) ~= "table" then table.insert(errors, "Component '" .. componentName .. "' insets must be a table") else -- If insets are provided, all 4 sides must be present for _, side in ipairs({ "left", "top", "right", "bottom" }) do if component.insets[side] == nil then table.insert(errors, "Component '" .. componentName .. "' insets must have '" .. side .. "' field") elseif type(component.insets[side]) ~= "number" then table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be a number") elseif component.insets[side] < 0 then table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be non-negative") end end end end -- Validate states if present if component.states ~= nil then if type(component.states) ~= "table" then table.insert(errors, "Component '" .. componentName .. "' states must be a table") else for stateName, stateComponent in pairs(component.states) do if type(stateComponent) ~= "table" then table.insert(errors, "Component '" .. componentName .. "' state '" .. stateName .. "' must be a table") end end end end -- Validate scaleCorners if present if component.scaleCorners ~= nil then if type(component.scaleCorners) ~= "number" then table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be a number") elseif component.scaleCorners <= 0 then table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be positive") end end -- Validate scalingAlgorithm if present if component.scalingAlgorithm ~= nil then if type(component.scalingAlgorithm) ~= "string" then table.insert(errors, "Component '" .. componentName .. "' scalingAlgorithm must be a string") elseif component.scalingAlgorithm ~= "nearest" and component.scalingAlgorithm ~= "bilinear" then table.insert(errors, "Component '" .. componentName .. "' scalingAlgorithm must be 'nearest' or 'bilinear'") end end end end end end -- contentAutoSizingMultiplier validation (optional) if theme.contentAutoSizingMultiplier ~= nil then if type(theme.contentAutoSizingMultiplier) ~= "table" then table.insert(errors, "Theme 'contentAutoSizingMultiplier' must be a table") else if theme.contentAutoSizingMultiplier.width ~= nil then if type(theme.contentAutoSizingMultiplier.width) ~= "number" then table.insert(errors, "contentAutoSizingMultiplier.width must be a number") elseif theme.contentAutoSizingMultiplier.width <= 0 then table.insert(errors, "contentAutoSizingMultiplier.width must be positive") end end if theme.contentAutoSizingMultiplier.height ~= nil then if type(theme.contentAutoSizingMultiplier.height) ~= "number" then table.insert(errors, "contentAutoSizingMultiplier.height must be a number") elseif theme.contentAutoSizingMultiplier.height <= 0 then table.insert(errors, "contentAutoSizingMultiplier.height must be positive") end end end end -- Global atlas validation (optional) if theme.atlas ~= nil then if type(theme.atlas) ~= "string" then table.insert(errors, "Theme 'atlas' must be a string") end end -- Strict mode: warn about unknown fields if options.strict then local knownFields = { name = true, atlas = true, components = true, colors = true, fonts = true, contentAutoSizingMultiplier = true, } for field in pairs(theme) do if not knownFields[field] then table.insert(errors, "Unknown field '" .. field .. "' in theme") end end end return #errors == 0, errors end --- Clean up malformed theme data to make it usable without crashing --- Use this to robustly handle user-created or external themes ---@param theme table? The theme to sanitize ---@return table sanitized The sanitized theme function Theme.sanitizeTheme(theme) local sanitized = {} -- Handle nil theme if theme == nil then return { name = "Invalid Theme" } end -- Handle non-table theme if type(theme) ~= "table" then return { name = "Invalid Theme" } end -- Sanitize name if type(theme.name) == "string" and theme.name ~= "" then sanitized.name = theme.name else sanitized.name = "Unnamed Theme" end -- Sanitize colors if type(theme.colors) == "table" then sanitized.colors = {} for colorName, colorValue in pairs(theme.colors) do if type(colorName) == "string" then local colorType = type(colorValue) if colorType == "table" and colorValue.r and colorValue.g and colorValue.b then -- Valid Color object sanitized.colors[colorName] = colorValue elseif colorType == "string" then -- Try to validate color string local isValid = Theme._Color.validateColor(colorValue) if isValid then sanitized.colors[colorName] = colorValue else -- Provide fallback color sanitized.colors[colorName] = Theme._Color.new(0, 0, 0, 1) end end end end end -- Sanitize fonts if type(theme.fonts) == "table" then sanitized.fonts = {} for fontName, fontPath in pairs(theme.fonts) do if type(fontName) == "string" and type(fontPath) == "string" then sanitized.fonts[fontName] = fontPath end end end -- Sanitize components (preserve as-is, they're complex) if type(theme.components) == "table" then sanitized.components = theme.components end -- Sanitize contentAutoSizingMultiplier if type(theme.contentAutoSizingMultiplier) == "table" then sanitized.contentAutoSizingMultiplier = {} if type(theme.contentAutoSizingMultiplier.width) == "number" and theme.contentAutoSizingMultiplier.width > 0 then sanitized.contentAutoSizingMultiplier.width = theme.contentAutoSizingMultiplier.width end if type(theme.contentAutoSizingMultiplier.height) == "number" and theme.contentAutoSizingMultiplier.height > 0 then sanitized.contentAutoSizingMultiplier.height = theme.contentAutoSizingMultiplier.height end end -- Sanitize atlas if type(theme.atlas) == "string" then sanitized.atlas = theme.atlas end return sanitized end return Theme