better docs/error reporting

This commit is contained in:
Michael Freno
2025-11-17 09:28:41 -05:00
parent d7ace1d535
commit a8be1f5342
5 changed files with 313 additions and 149 deletions

View File

@@ -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 ImageRenderer.init then
ImageRenderer.init(errorHandlerDeps)
end
if ImageScaler then
local ImageScaler = req("ImageScaler")
if ImageScaler.init then ImageScaler.init(errorHandlerDeps) end
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 }

View File

@@ -1,4 +1,8 @@
--- Easing function type
---@alias EasingFunction fun(t: number): number
--- Easing functions for animations
---@type table<string, EasingFunction>
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 = {},
})

View File

@@ -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

View File

@@ -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<string> -- 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<string>|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<string, Color>|nil -- Table of all colors, or nil if no theme active
---@return table<string, Color>? 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

View File

@@ -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()