diff --git a/FlexLove.lua b/FlexLove.lua index b15aff5..46d919d 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -8,10 +8,12 @@ local Blur = req("Blur") local utils = req("utils") local Units = req("Units") local Context = req("Context") +---@type StateManager local StateManager = req("StateManager") local ErrorCodes = req("ErrorCodes") local ErrorHandler = req("ErrorHandler") local ImageRenderer = req("ImageRenderer") +local ImageScaler = req("ImageScaler") local NinePatch = req("NinePatch") local RoundedRect = req("RoundedRect") local ImageCache = req("ImageCache") @@ -41,6 +43,7 @@ Element.defaultDependencies = { Units = Units, Blur = Blur, ImageRenderer = ImageRenderer, + ImageScaler = ImageScaler, NinePatch = NinePatch, RoundedRect = RoundedRect, ImageCache = ImageCache, @@ -64,14 +67,22 @@ ErrorHandler.init({ ErrorCodes = ErrorCodes }) -- Initialize modules that use ErrorHandler via DI local errorHandlerDeps = { ErrorHandler = ErrorHandler } -if ImageRenderer.init then ImageRenderer.init(errorHandlerDeps) end -if ImageScaler then - local ImageScaler = req("ImageScaler") - if ImageScaler.init then ImageScaler.init(errorHandlerDeps) end +if ImageRenderer.init then + ImageRenderer.init(errorHandlerDeps) +end +if ImageScaler then + local ImageScaler = req("ImageScaler") + if ImageScaler.init then + ImageScaler.init(errorHandlerDeps) + end +end +if NinePatch.init then + NinePatch.init(errorHandlerDeps) end -if NinePatch.init then NinePatch.init(errorHandlerDeps) end local ImageDataReader = req("ImageDataReader") -if ImageDataReader.init then ImageDataReader.init(errorHandlerDeps) end +if ImageDataReader.init then + ImageDataReader.init(errorHandlerDeps) +end -- Initialize Units module with Context dependency Units.initialize(Context) @@ -111,6 +122,7 @@ flexlove._LICENSE = [[ SOFTWARE. ]] +--- Initialize FlexLove with configuration options, set refence scale for autoscaling on window resize, immediate mode, and error logging / error file path ---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition, immediateMode?: boolean, stateRetentionFrames?: number, maxStateEntries?: number, autoFrameManagement?: boolean, errorLogFile?: string, enableErrorLogging?: boolean} function flexlove.init(config) config = config or {} @@ -186,6 +198,7 @@ function flexlove.resize() end end +--- Can also be set in init() ---@param mode "immediate"|"retained" function flexlove.setMode(mode) if mode == "immediate" then @@ -211,6 +224,7 @@ function flexlove.getMode() end --- Begin a new immediate mode frame +--- You do NOT need to call this directly, it will autodetect, but you can if you need more granular control - must then paired with endFrame() function flexlove.beginFrame() if not flexlove._immediateMode then return @@ -225,6 +239,9 @@ function flexlove.beginFrame() Context.clearFrameElements() end +--- End the current immediate mode frame +--- You do NOT need to call this directly unless you call beginFrame() manually - it will autodetect, but you can if you need more granular control +--- MUST BE PAIRED WITH beginFrame() function flexlove.endFrame() if not flexlove._immediateMode then return @@ -290,8 +307,8 @@ flexlove._gameCanvas = nil flexlove._backdropCanvas = nil flexlove._canvasDimensions = { width = 0, height = 0 } ----@param gameDrawFunc function|nil ----@param postDrawFunc function|nil +---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur +---@param postDrawFunc function|nil pass component draws that should NOT be affected by a backdrop blur function flexlove.draw(gameDrawFunc, postDrawFunc) if flexlove._immediateMode and flexlove._autoBeganFrame then flexlove.endFrame() @@ -500,6 +517,7 @@ function flexlove.getElementAtPosition(x, y) return blockingElements[1] end +---@param dt number function flexlove.update(dt) local mx, my = love.mouse.getPosition() local topElement = flexlove.getElementAtPosition(mx, my) @@ -549,6 +567,8 @@ function flexlove.keypressed(key, scancode, isrepeat) end end +---@param dx number +---@param dy number function flexlove.wheelmoved(dx, dy) local mx, my = love.mouse.getPosition() @@ -673,6 +693,7 @@ function flexlove.wheelmoved(dx, dy) end end +--- destroys all top-level elements and resets the framework state function flexlove.destroy() for _, win in ipairs(flexlove.topElements) do win:destroy() @@ -685,6 +706,7 @@ function flexlove.destroy() flexlove._backdropCanvas = nil flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._focusedElement = nil + StateManager:reset() end ---@param props ElementProps @@ -819,8 +841,8 @@ function flexlove.clearAllStates() StateManager.clearAllStates() end ---- Get state statistics (for debugging) ----@return table +--- Get state (immediate mode) statistics (for debugging) +---@return { stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil } function flexlove.getStateStats() if not flexlove._immediateMode then return { stateCount = 0, frameNumber = 0 } diff --git a/modules/Animation.lua b/modules/Animation.lua index ca40189..b96cd01 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -1,4 +1,8 @@ +--- Easing function type +---@alias EasingFunction fun(t: number): number + --- Easing functions for animations +---@type table local Easing = { linear = function(t) return t @@ -40,19 +44,48 @@ local Easing = { return t == 1 and 1 or 1 - math.pow(2, -10 * t) end, } +---@class AnimationProps +---@field duration number Duration in seconds +---@field start {width?:number, height?:number, opacity?:number} Starting values +---@field final {width?:number, height?:number, opacity?:number} Final values +---@field easing string? Easing function name (default: "linear") +---@field transform table? Additional transform properties +---@field transition table? Transition properties + ---@class Animation ----@field duration number ----@field start {width?:number, height?:number, opacity?:number} ----@field final {width?:number, height?:number, opacity?:number} ----@field elapsed number ----@field transform table? ----@field transition table? +---@field duration number Duration in seconds +---@field start {width?:number, height?:number, opacity?:number} Starting values +---@field final {width?:number, height?:number, opacity?:number} Final values +---@field elapsed number Elapsed time in seconds +---@field easing EasingFunction Easing function +---@field transform table? Additional transform properties +---@field transition table? Transition properties +---@field _cachedResult table Cached interpolation result +---@field _resultDirty boolean Whether cached result needs recalculation local Animation = {} Animation.__index = Animation ----@param props AnimationProps ----@return Animation +---Create a new animation instance +---@param props AnimationProps Animation properties +---@return Animation animation The new animation instance function Animation.new(props) + -- Validate input + if type(props) ~= "table" then + error("[FlexLove.Animation] Animation.new() requires a table argument") + end + + if type(props.duration) ~= "number" or props.duration <= 0 then + error("[FlexLove.Animation] Animation duration must be a positive number") + end + + if type(props.start) ~= "table" then + error("[FlexLove.Animation] Animation start must be a table") + end + + if type(props.final) ~= "table" then + error("[FlexLove.Animation] Animation final must be a table") + end + local self = setmetatable({}, Animation) self.duration = props.duration self.start = props.start @@ -61,8 +94,15 @@ function Animation.new(props) self.transition = props.transition self.elapsed = 0 + -- Validate and set easing function local easingName = props.easing or "linear" - self.easing = Easing[easingName] or Easing.linear + if type(easingName) == "string" then + self.easing = Easing[easingName] or Easing.linear + elseif type(easingName) == "function" then + self.easing = easingName + else + self.easing = Easing.linear + end -- Pre-allocate result table to avoid GC pressure self._cachedResult = {} @@ -71,9 +111,15 @@ function Animation.new(props) return self end ----@param dt number ----@return boolean +---Update the animation with delta time +---@param dt number Delta time in seconds +---@return boolean completed True if animation is complete function Animation:update(dt) + -- Sanitize dt + if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then + dt = 0 + end + self.elapsed = self.elapsed + dt self._resultDirty = true if self.elapsed >= self.duration then @@ -83,7 +129,8 @@ function Animation:update(dt) end end ----@return table +---Interpolate animation values at current time +---@return table result Interpolated values {width?, height?, opacity?, ...} function Animation:interpolate() -- Return cached result if not dirty (avoids recalculation) if not self._resultDirty then @@ -91,26 +138,36 @@ function Animation:interpolate() end local t = math.min(self.elapsed / self.duration, 1) - t = self.easing(t) + + -- Apply easing function with protection + local success, easedT = pcall(self.easing, t) + if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then + easedT = t -- Fallback to linear if easing fails + end + local result = self._cachedResult -- Reuse existing table result.width = nil result.height = nil result.opacity = nil - if self.start.width and self.final.width then - result.width = self.start.width * (1 - t) + self.final.width * t + -- Interpolate width if both start and final are valid numbers + if type(self.start.width) == "number" and type(self.final.width) == "number" then + result.width = self.start.width * (1 - easedT) + self.final.width * easedT end - if self.start.height and self.final.height then - result.height = self.start.height * (1 - t) + self.final.height * t + -- Interpolate height if both start and final are valid numbers + if type(self.start.height) == "number" and type(self.final.height) == "number" then + result.height = self.start.height * (1 - easedT) + self.final.height * easedT end - if self.start.opacity and self.final.opacity then - result.opacity = self.start.opacity * (1 - t) + self.final.opacity * t + -- Interpolate opacity if both start and final are valid numbers + if type(self.start.opacity) == "number" and type(self.final.opacity) == "number" then + result.opacity = self.start.opacity * (1 - easedT) + self.final.opacity * easedT end - if self.transform then + -- Copy transform properties + if self.transform and type(self.transform) == "table" then for key, value in pairs(self.transform) do result[key] = value end @@ -120,36 +177,66 @@ function Animation:interpolate() return result end ----@param element Element +---Apply this animation to an element +---@param element Element The element to apply animation to function Animation:apply(element) + if not element or type(element) ~= "table" then + error("[FlexLove.Animation] Cannot apply animation to nil or non-table element") + end element.animation = self end --- Create a simple fade animation ----@param duration number ----@param fromOpacity number ----@param toOpacity number ----@return Animation -function Animation.fade(duration, fromOpacity, toOpacity) +---@param duration number Duration in seconds +---@param fromOpacity number Starting opacity (0-1) +---@param toOpacity number Ending opacity (0-1) +---@param easing string? Easing function name (default: "linear") +---@return Animation animation The fade animation +function Animation.fade(duration, fromOpacity, toOpacity, easing) + -- Sanitize inputs + if type(duration) ~= "number" or duration <= 0 then + duration = 1 + end + if type(fromOpacity) ~= "number" then + fromOpacity = 1 + end + if type(toOpacity) ~= "number" then + toOpacity = 0 + end + return Animation.new({ duration = duration, start = { opacity = fromOpacity }, final = { opacity = toOpacity }, + easing = easing, transform = {}, transition = {}, }) end --- Create a simple scale animation ----@param duration number ----@param fromScale table{width:number,height:number} ----@param toScale table{width:number,height:number} ----@return Animation -function Animation.scale(duration, fromScale, toScale) +---@param duration number Duration in seconds +---@param fromScale {width:number,height:number} Starting scale +---@param toScale {width:number,height:number} Ending scale +---@param easing string? Easing function name (default: "linear") +---@return Animation animation The scale animation +function Animation.scale(duration, fromScale, toScale, easing) + -- Sanitize inputs + if type(duration) ~= "number" or duration <= 0 then + duration = 1 + end + if type(fromScale) ~= "table" then + fromScale = { width = 1, height = 1 } + end + if type(toScale) ~= "table" then + toScale = { width = 1, height = 1 } + end + return Animation.new({ duration = duration, - start = { width = fromScale.width, height = fromScale.height }, - final = { width = toScale.width, height = toScale.height }, + start = { width = fromScale.width or 0, height = fromScale.height or 0 }, + final = { width = toScale.width or 0, height = toScale.height or 0 }, + easing = easing, transform = {}, transition = {}, }) diff --git a/modules/Color.lua b/modules/Color.lua index 4533265..7348cb1 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -54,31 +54,53 @@ local Color = {} Color.__index = Color --- Create a new color instance ----@param r number? ----@param g number? ----@param b number? ----@param a number? ----@return Color +---@param r number? Red component (0-1), defaults to 0 +---@param g number? Green component (0-1), defaults to 0 +---@param b number? Blue component (0-1), defaults to 0 +---@param a number? Alpha component (0-1), defaults to 1 +---@return Color color The new color instance function Color.new(r, g, b, a) local self = setmetatable({}, Color) - self.r = r or 0 - self.g = g or 0 - self.b = b or 0 - self.a = a or 1 + + -- Sanitize and clamp color components + local _, sanitizedR = Color.validateColorChannel(r or 0, 1) + local _, sanitizedG = Color.validateColorChannel(g or 0, 1) + local _, sanitizedB = Color.validateColorChannel(b or 0, 1) + local _, sanitizedA = Color.validateColorChannel(a or 1, 1) + + self.r = sanitizedR or 0 + self.g = sanitizedG or 0 + self.b = sanitizedB or 0 + self.a = sanitizedA or 1 return self end ----@return number r, number g, number b, number a +---Convert color to RGBA components +---@return number r Red component (0-1) +---@return number g Green component (0-1) +---@return number b Blue component (0-1) +---@return number a Alpha component (0-1) function Color:toRGBA() return self.r, self.g, self.b, self.a end --- Convert hex string to color --- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats ----@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA" ----@return Color ----@throws Error if hex string format is invalid +---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA") +---@return Color color The parsed color (returns white on error with warning) function Color.fromHex(hexWithTag) + -- Validate input type + if type(hexWithTag) ~= "string" then + if ErrorHandler then + ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { + input = tostring(hexWithTag), + issue = "not a string", + fallback = "white (#FFFFFF)" + }) + end + return Color.new(1, 1, 1, 1) + end + local hex = hexWithTag:gsub("#", "") if #hex == 6 then local r = tonumber("0x" .. hex:sub(1, 2)) @@ -125,10 +147,10 @@ function Color.fromHex(hexWithTag) end --- Validate a single color channel value ----@param value any -- Value to validate ----@param max number? -- Maximum value (255 for 0-255 range, 1 for 0-1 range) ----@return boolean valid -- True if valid ----@return number? clamped -- Clamped value in 0-1 range +---@param value any Value to validate +---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1 +---@return boolean valid True if valid +---@return number? clamped Clamped value in 0-1 range, nil if invalid function Color.validateColorChannel(value, max) max = max or 1 @@ -159,9 +181,9 @@ function Color.validateColorChannel(value, max) end --- Validate hex color format ----@param hex string -- Hex color string (with or without #) ----@return boolean valid -- True if valid format ----@return string? error -- Error message if invalid +---@param hex string Hex color string (with or without #) +---@return boolean valid True if valid format +---@return string? error Error message if invalid, nil if valid function Color.validateHexColor(hex) if type(hex) ~= "string" then return false, "Hex color must be a string" @@ -184,13 +206,13 @@ function Color.validateHexColor(hex) end --- Validate RGB/RGBA color values ----@param r number -- Red component ----@param g number -- Green component ----@param b number -- Blue component ----@param a number? -- Alpha component (optional) ----@param max number? -- Maximum value (255 or 1) ----@return boolean valid -- True if valid ----@return string? error -- Error message if invalid +---@param r number Red component +---@param g number Green component +---@param b number Blue component +---@param a number? Alpha component (optional, defaults to max) +---@param max number? Maximum value (255 or 1), defaults to 1 +---@return boolean valid True if valid +---@return string? error Error message if invalid, nil if valid function Color.validateRGBColor(r, g, b, a, max) max = max or 1 a = a or max @@ -217,9 +239,9 @@ function Color.validateRGBColor(r, g, b, a, max) end --- Validate named color ----@param name string -- Color name ----@return boolean valid -- True if valid ----@return string? error -- Error message if invalid +---@param name string Color name (e.g. "red", "blue", "transparent") +---@return boolean valid True if valid +---@return string? error Error message if invalid, nil if valid function Color.validateNamedColor(name) if type(name) ~= "string" then return false, "Color name must be a string" @@ -234,8 +256,8 @@ function Color.validateNamedColor(name) end --- Check if a value is a valid color format ----@param value any -- Value to check ----@return string? format -- Format type (hex, rgb, rgba, named, table, nil if invalid) +---@param value any Value to check +---@return string? format Format type ("hex", "named", "table"), nil if invalid function Color.isValidColorFormat(value) local valueType = type(value) @@ -286,10 +308,10 @@ function Color.isValidColorFormat(value) end --- Validate a color value ----@param value any -- Color value to validate ----@param options table? -- Validation options ----@return boolean valid -- True if valid ----@return string? error -- Error message if invalid +---@param value any Color value to validate +---@param options table? Validation options {allowNamed: boolean, requireAlpha: boolean} +---@return boolean valid True if valid +---@return string? error Error message if invalid, nil if valid function Color.validateColor(value, options) options = options or {} local allowNamed = options.allowNamed ~= false @@ -320,10 +342,10 @@ function Color.validateColor(value, options) return true, nil end ---- Sanitize a color value ----@param value any -- Color value to sanitize ----@param default Color? -- Default color if invalid ----@return Color -- Sanitized color +--- Sanitize a color value (always returns a valid Color) +---@param value any Color value to sanitize (hex, named, table, or Color instance) +---@param default Color? Default color if invalid (defaults to black) +---@return Color color Sanitized color instance (guaranteed non-nil) function Color.sanitizeColor(value, default) default = default or Color.new(0, 0, 0, 1) @@ -396,9 +418,9 @@ function Color.sanitizeColor(value, default) return default end ---- Parse a color from various formats ----@param value any -- Color value (hex, named, table) ----@return Color -- Parsed color +--- Parse a color from various formats (always returns a valid Color) +---@param value any Color value (hex string, named color, table, or Color instance) +---@return Color color Parsed color instance (defaults to black on error) function Color.parse(value) return Color.sanitizeColor(value, Color.new(0, 0, 0, 1)) end diff --git a/modules/Theme.lua b/modules/Theme.lua index f693c7b..6e3acae 100644 --- a/modules/Theme.lua +++ b/modules/Theme.lua @@ -124,7 +124,17 @@ Theme.__index = Theme local themes = {} local activeTheme = nil +---Create a new theme instance +---@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 + ErrorHandler.error("Theme", "THM_001", "Invalid theme definition", { + error = "Theme definition must be a table, got " .. type(definition) + }) + end + -- Validate theme definition local valid, err = validateThemeDefinition(definition) if not valid then @@ -303,8 +313,8 @@ function Theme.new(definition) end --- Load a theme from a Lua file ----@param path string -- Path to theme definition file (e.g., "space" or "mytheme") ----@return Theme +---@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 @@ -338,7 +348,8 @@ function Theme.load(path) return theme end ----@param themeOrName Theme|string +---Set the active theme +---@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 @@ -361,15 +372,15 @@ function Theme.setActive(themeOrName) end --- Get the active theme ----@return Theme? +---@return Theme? theme The active theme, or nil if none is active function Theme.getActive() return activeTheme end --- Get a component from the active theme ----@param componentName string -- Name of the component (e.g., "button", "panel") ----@param state string? -- Optional state (e.g., "hover", "pressed", "disabled") ----@return ThemeComponent? -- Returns component or nil if not found +---@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 @@ -389,8 +400,8 @@ function Theme.getComponent(componentName, state) end --- Get a font from the active theme ----@param fontName string -- Name of the font family (e.g., "default", "heading") ----@return string? -- Returns font path or nil if not found +---@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 @@ -400,8 +411,8 @@ function Theme.getFont(fontName) end --- Get a color from the active theme ----@param colorName string -- Name of the color (e.g., "primary", "secondary") ----@return Color? -- Returns Color instance or nil if not found +---@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 @@ -411,13 +422,13 @@ function Theme.getColor(colorName) end --- Check if a theme is currently active ----@return boolean -- Returns true if a theme is active +---@return boolean active Returns true if a theme is active function Theme.hasActive() return activeTheme ~= nil end --- Get all registered theme names ----@return table -- Array of theme names +---@return string[] themeNames Array of theme names function Theme.getRegisteredThemes() local themeNames = {} for name, _ in pairs(themes) do @@ -427,7 +438,7 @@ function Theme.getRegisteredThemes() end --- Get all available color names from the active theme ----@return table|nil -- Array of color names, or nil if no theme active +---@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 @@ -441,7 +452,7 @@ function Theme.getColorNames() end --- Get all colors from the active theme ----@return table|nil -- Table of all colors, or nil if no theme active +---@return table? colors Table of all colors, or nil if no theme active function Theme.getAllColors() if not activeTheme then return nil @@ -451,9 +462,9 @@ function Theme.getAllColors() end --- Get a color with a fallback if not found ----@param colorName string -- Name of the color to retrieve ----@param fallback Color|nil -- Fallback color if not found (default: white) ----@return Color -- The color or fallback +---@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 @@ -464,8 +475,8 @@ function Theme.getColorOrDefault(colorName, fallback) end --- Get a theme by name ----@param themeName string -- Name of the theme ----@return Theme|nil -- Returns theme or nil if not found +---@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 @@ -487,8 +498,9 @@ end local ThemeManager = {} ThemeManager.__index = ThemeManager ----@param config table Configuration options ----@return 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) @@ -506,16 +518,18 @@ function ThemeManager.new(config) return self end ----@param element table The parent Element +---Initialize the ThemeManager with a parent element +---@param element Element The parent Element function ThemeManager:initialize(element) self._element = element 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 The new theme state +---@return string state The new theme state ("normal", "hover", "pressed", "active", "disabled") function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) local newState = "normal" @@ -533,22 +547,29 @@ function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) return newState end ----@return string The current theme state +---Get the current theme state +---@return string state The current theme state function ThemeManager:getState() return self._themeState end ----@param state string The theme state to set +---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 ----@return boolean +---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 ----@return table? +---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) @@ -556,21 +577,27 @@ function ThemeManager:getTheme() return Theme.getActive() end ----@return table? +---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[self.themeComponent] then + 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 ----@return table? +---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 @@ -578,27 +605,33 @@ function ThemeManager:getStateComponent() end local state = self._themeState - if state and state ~= "normal" and component.states and component.states[state] then + 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 ----@param property string ----@return any? +---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 then + if not stateComponent or type(stateComponent) ~= "table" then return nil end return stateComponent[property] end ----@param borderBoxWidth number ----@param borderBoxHeight number ----@return table? {left, top, right, bottom} or nil if no contentPadding +---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 @@ -639,7 +672,8 @@ function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) return nil end ----@return number? +---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 @@ -650,7 +684,7 @@ function ThemeManager:getContentAutoSizingMultiplier() return nil end - if self.themeComponent then + 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 @@ -666,17 +700,19 @@ function ThemeManager:getContentAutoSizingMultiplier() return nil end ----@return string? +---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 themeToUse.fonts["default"] then + if themeToUse and themeToUse.fonts and type(themeToUse.fonts) == "table" and themeToUse.fonts["default"] then return themeToUse.fonts["default"] end return nil end ----@param themeName string? The theme name ----@param componentName string? The component name +---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 diff --git a/testing/__tests__/animation_test.lua b/testing/__tests__/animation_test.lua index b6f7fda..c4ce95f 100644 --- a/testing/__tests__/animation_test.lua +++ b/testing/__tests__/animation_test.lua @@ -22,28 +22,25 @@ function TestAnimation:testNewWithNilDuration() end function TestAnimation:testNewWithNegativeDuration() - -- Should still create but behave oddly - local anim = Animation.new({ - duration = -1, - start = { opacity = 0 }, - final = { opacity = 1 }, - }) - luaunit.assertNotNil(anim) - -- Update with positive dt should immediately finish - local done = anim:update(1) - luaunit.assertTrue(done) + -- Should throw an error for invalid duration + luaunit.assertErrorMsgContains("duration must be a positive number", function() + Animation.new({ + duration = -1, + start = { opacity = 0 }, + final = { opacity = 1 }, + }) + end) end function TestAnimation:testNewWithZeroDuration() - local anim = Animation.new({ - duration = 0, - start = { opacity = 0 }, - final = { opacity = 1 }, - }) - luaunit.assertNotNil(anim) - -- Should be instantly complete - local done = anim:update(0.001) - luaunit.assertTrue(done) + -- Should throw an error for invalid duration + luaunit.assertErrorMsgContains("duration must be a positive number", function() + Animation.new({ + duration = 0, + start = { opacity = 0 }, + final = { opacity = 1 }, + }) + end) end function TestAnimation:testNewWithInvalidEasing()