better docs/error reporting
This commit is contained in:
38
FlexLove.lua
38
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 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 }
|
||||
|
||||
@@ -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 = {},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user