Performance and reporting improvements

This commit is contained in:
Michael Freno
2025-11-17 17:41:01 -05:00
parent a8be1f5342
commit 2c04f69daa
18 changed files with 1987 additions and 82 deletions

View File

@@ -12,6 +12,7 @@ local Context = req("Context")
local StateManager = req("StateManager") local StateManager = req("StateManager")
local ErrorCodes = req("ErrorCodes") local ErrorCodes = req("ErrorCodes")
local ErrorHandler = req("ErrorHandler") local ErrorHandler = req("ErrorHandler")
local Performance = req("Performance")
local ImageRenderer = req("ImageRenderer") local ImageRenderer = req("ImageRenderer")
local ImageScaler = req("ImageScaler") local ImageScaler = req("ImageScaler")
local NinePatch = req("NinePatch") local NinePatch = req("NinePatch")
@@ -95,7 +96,7 @@ Color.initializeErrorHandler(ErrorHandler)
utils.initializeErrorHandler(ErrorHandler) utils.initializeErrorHandler(ErrorHandler)
-- Add version and metadata -- Add version and metadata
flexlove._VERSION = "0.2.2" flexlove._VERSION = "0.2.3"
flexlove._DESCRIPTION = "0I Library for LÖVE Framework based on flexbox" flexlove._DESCRIPTION = "0I Library for LÖVE Framework based on flexbox"
flexlove._URL = "https://github.com/mikefreno/FlexLove" flexlove._URL = "https://github.com/mikefreno/FlexLove"
flexlove._LICENSE = [[ flexlove._LICENSE = [[
@@ -122,12 +123,27 @@ flexlove._LICENSE = [[
SOFTWARE. SOFTWARE.
]] ]]
-- GC (Garbage Collection) configuration
flexlove._gcConfig = {
strategy = "auto", -- "auto", "periodic", "manual", "disabled"
memoryThreshold = 100, -- MB before forcing GC
interval = 60, -- Frames between GC steps (for periodic mode)
stepSize = 200, -- Work units per GC step (higher = more aggressive)
}
flexlove._gcState = {
framesSinceLastGC = 0,
lastMemory = 0,
gcCount = 0,
}
-- Deferred callback queue for operations that cannot run while Canvas is active
flexlove._deferredCallbacks = {}
--- Initialize FlexLove with configuration options, set refence scale for autoscaling on window resize, immediate mode, and error logging / error file path --- 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} ---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition, immediateMode?: boolean, stateRetentionFrames?: number, maxStateEntries?: number, autoFrameManagement?: boolean, errorLogFile?: string, enableErrorLogging?: boolean, performanceMonitoring?: boolean, performanceWarnings?: boolean, performanceHudKey?: string, performanceHudPosition?: {x: number, y: number} }
function flexlove.init(config) function flexlove.init(config)
config = config or {} config = config or {}
-- Configure error logging
if config.errorLogFile then if config.errorLogFile then
ErrorHandler.setLogTarget("file") ErrorHandler.setLogTarget("file")
ErrorHandler.setLogFile(config.errorLogFile) ErrorHandler.setLogFile(config.errorLogFile)
@@ -137,6 +153,43 @@ function flexlove.init(config)
ErrorHandler.setLogFile("flexlove-errors.log") ErrorHandler.setLogFile("flexlove-errors.log")
end end
-- Configure performance monitoring (default: true)
local enablePerfMonitoring = config.performanceMonitoring
if enablePerfMonitoring == nil then
enablePerfMonitoring = true
end
if enablePerfMonitoring then
Performance.enable()
else
Performance.disable()
end
local enablePerfWarnings = config.performanceWarnings or true
Performance.setConfig("warningsEnabled", enablePerfWarnings)
if enablePerfWarnings then
Performance.setConfig("logWarnings", true)
end
-- Configure performance HUD toggle key (default: "f3")
if config.performanceHudKey then
Performance.setConfig("hudToggleKey", config.performanceHudKey)
end
-- Configure performance HUD position (default: {x = 10, y = 10})
if config.performanceHudPosition then
Performance.setConfig("hudPosition", config.performanceHudPosition)
end
-- Configure memory profiling (default: false)
if config.memoryProfiling then
Performance.enableMemoryProfiling()
-- Register key tables for leak detection
Performance.registerTableForMonitoring("StateManager.stateStore", StateManager._getInternalState().stateStore)
Performance.registerTableForMonitoring("StateManager.stateMetadata", StateManager._getInternalState().stateMetadata)
Performance.registerTableForMonitoring("FONT_CACHE", utils.FONT_CACHE)
end
if config.baseScale then if config.baseScale then
flexlove.baseScale = { flexlove.baseScale = {
width = config.baseScale.width or 1920, width = config.baseScale.width or 1920,
@@ -171,6 +224,20 @@ function flexlove.init(config)
flexlove._autoFrameManagement = config.autoFrameManagement or false flexlove._autoFrameManagement = config.autoFrameManagement or false
-- Configure GC strategy
if config.gcStrategy then
flexlove._gcConfig.strategy = config.gcStrategy
end
if config.gcMemoryThreshold then
flexlove._gcConfig.memoryThreshold = config.gcMemoryThreshold
end
if config.gcInterval then
flexlove._gcConfig.interval = config.gcInterval
end
if config.gcStepSize then
flexlove._gcConfig.stepSize = config.gcStepSize
end
if config.stateRetentionFrames or config.maxStateEntries then if config.stateRetentionFrames or config.maxStateEntries then
StateManager.configure({ StateManager.configure({
stateRetentionFrames = config.stateRetentionFrames, stateRetentionFrames = config.stateRetentionFrames,
@@ -179,6 +246,45 @@ function flexlove.init(config)
end end
end end
--- Queue a callback to be executed after the current frame's canvas operations complete
--- This is necessary for operations that cannot run while a Canvas is active (e.g., love.window.setMode)
---@param callback function The callback to execute
function flexlove.deferCallback(callback)
if type(callback) ~= "function" then
ErrorHandler.warn("FlexLove", "deferCallback expects a function")
return
end
table.insert(flexlove._deferredCallbacks, callback)
end
--- Execute all deferred callbacks and clear the queue
--- IMPORTANT: This MUST be called at the very end of love.draw() after ALL canvases
--- have been released, including any canvases created by the application (not just FlexLove's canvases)
--- @usage
--- function love.draw()
--- love.graphics.setCanvas(myCanvas)
--- FlexLove.draw()
--- love.graphics.setCanvas() -- Release ALL canvases
--- FlexLove.executeDeferredCallbacks() -- Now safe to execute
--- end
function flexlove.executeDeferredCallbacks()
if #flexlove._deferredCallbacks == 0 then
return
end
-- Copy callbacks and clear queue before execution
-- This prevents infinite loops if callbacks defer more callbacks
local callbacks = flexlove._deferredCallbacks
flexlove._deferredCallbacks = {}
for _, callback in ipairs(callbacks) do
local success, err = xpcall(callback, debug.traceback)
if not success then
ErrorHandler.warn("FlexLove", string.format("Deferred callback failed: %s", tostring(err)))
end
end
end
function flexlove.resize() function flexlove.resize()
local newWidth, newHeight = love.window.getMode() local newWidth, newHeight = love.window.getMode()
@@ -189,6 +295,14 @@ function flexlove.resize()
Blur.clearCache() Blur.clearCache()
-- Release old canvases explicitly
if flexlove._gameCanvas then
flexlove._gameCanvas:release()
end
if flexlove._backdropCanvas then
flexlove._backdropCanvas:release()
end
flexlove._gameCanvas = nil flexlove._gameCanvas = nil
flexlove._backdropCanvas = nil flexlove._backdropCanvas = nil
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }
@@ -230,6 +344,9 @@ function flexlove.beginFrame()
return return
end end
-- Start performance frame timing
Performance.startFrame()
flexlove._frameNumber = flexlove._frameNumber + 1 flexlove._frameNumber = flexlove._frameNumber + 1
StateManager.incrementFrame() StateManager.incrementFrame()
flexlove._currentFrameElements = {} flexlove._currentFrameElements = {}
@@ -301,6 +418,10 @@ function flexlove.endFrame()
StateManager.cleanup() StateManager.cleanup()
StateManager.forceCleanupIfNeeded() StateManager.forceCleanupIfNeeded()
flexlove._frameStarted = false flexlove._frameStarted = false
-- End performance frame timing
Performance.endFrame()
Performance.resetFrameCounters()
end end
flexlove._gameCanvas = nil flexlove._gameCanvas = nil
@@ -322,6 +443,14 @@ function flexlove.draw(gameDrawFunc, postDrawFunc)
local width, height = love.graphics.getDimensions() local width, height = love.graphics.getDimensions()
if not flexlove._gameCanvas or flexlove._canvasDimensions.width ~= width or flexlove._canvasDimensions.height ~= height then if not flexlove._gameCanvas or flexlove._canvasDimensions.width ~= width or flexlove._canvasDimensions.height ~= height then
-- Release old canvases before creating new ones
if flexlove._gameCanvas then
flexlove._gameCanvas:release()
end
if flexlove._backdropCanvas then
flexlove._backdropCanvas:release()
end
flexlove._gameCanvas = love.graphics.newCanvas(width, height) flexlove._gameCanvas = love.graphics.newCanvas(width, height)
flexlove._backdropCanvas = love.graphics.newCanvas(width, height) flexlove._backdropCanvas = love.graphics.newCanvas(width, height)
flexlove._canvasDimensions.width = width flexlove._canvasDimensions.width = width
@@ -404,7 +533,15 @@ function flexlove.draw(gameDrawFunc, postDrawFunc)
postDrawFunc() postDrawFunc()
end end
-- Render performance HUD if enabled
Performance.renderHUD()
love.graphics.setCanvas(outerCanvas) love.graphics.setCanvas(outerCanvas)
-- NOTE: Deferred callbacks are NOT executed here because the calling code
-- (e.g., main.lua) might still have a canvas active. Callbacks must be
-- executed by calling FlexLove.executeDeferredCallbacks() at the very end
-- of love.draw() after ALL canvases have been released.
end end
---@param element Element ---@param element Element
@@ -519,6 +656,12 @@ end
---@param dt number ---@param dt number
function flexlove.update(dt) function flexlove.update(dt)
-- Update Performance module with actual delta time for accurate FPS
Performance.updateDeltaTime(dt)
-- Garbage collection management
flexlove._manageGC()
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
local topElement = flexlove.getElementAtPosition(mx, my) local topElement = flexlove.getElementAtPosition(mx, my)
@@ -551,6 +694,86 @@ function flexlove.update(dt)
end end
end end
--- Internal GC management function (called from update)
function flexlove._manageGC()
local strategy = flexlove._gcConfig.strategy
if strategy == "disabled" then
return
end
local currentMemory = collectgarbage("count") / 1024 -- Convert to MB
flexlove._gcState.lastMemory = currentMemory
flexlove._gcState.framesSinceLastGC = flexlove._gcState.framesSinceLastGC + 1
-- Check memory threshold (applies to all strategies except disabled)
if currentMemory > flexlove._gcConfig.memoryThreshold then
-- Force full GC when exceeding threshold
collectgarbage("collect")
flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1
flexlove._gcState.framesSinceLastGC = 0
return
end
-- Strategy-specific GC
if strategy == "periodic" then
-- Run incremental GC step every N frames
if flexlove._gcState.framesSinceLastGC >= flexlove._gcConfig.interval then
collectgarbage("step", flexlove._gcConfig.stepSize)
flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1
flexlove._gcState.framesSinceLastGC = 0
end
elseif strategy == "auto" then
-- Let Lua's automatic GC handle it, but help with incremental steps
-- Run a small step every frame to keep memory under control
if flexlove._gcState.framesSinceLastGC >= 5 then
collectgarbage("step", 50) -- Small steps to avoid frame drops
flexlove._gcState.framesSinceLastGC = 0
end
end
-- "manual" strategy: no automatic GC, user must call flexlove.collectGarbage()
end
--- Manual garbage collection control
---@param mode? string "collect" for full GC, "step" for incremental (default: "collect")
---@param stepSize? number Work units for step mode (default: 200)
function flexlove.collectGarbage(mode, stepSize)
mode = mode or "collect"
stepSize = stepSize or 200
if mode == "collect" then
collectgarbage("collect")
flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1
flexlove._gcState.framesSinceLastGC = 0
elseif mode == "step" then
collectgarbage("step", stepSize)
elseif mode == "count" then
return collectgarbage("count") / 1024 -- Return memory in MB
end
end
--- Set GC strategy
---@param strategy string "auto", "periodic", "manual", or "disabled"
function flexlove.setGCStrategy(strategy)
if strategy == "auto" or strategy == "periodic" or strategy == "manual" or strategy == "disabled" then
flexlove._gcConfig.strategy = strategy
else
ErrorHandler.warn("FlexLove", "Invalid GC strategy: " .. tostring(strategy))
end
end
--- Get GC statistics
---@return table stats {gcCount, framesSinceLastGC, currentMemoryMB, strategy}
function flexlove.getGCStats()
return {
gcCount = flexlove._gcState.gcCount,
framesSinceLastGC = flexlove._gcState.framesSinceLastGC,
currentMemoryMB = flexlove._gcState.lastMemory,
strategy = flexlove._gcConfig.strategy,
threshold = flexlove._gcConfig.memoryThreshold,
}
end
---@param text string ---@param text string
function flexlove.textinput(text) function flexlove.textinput(text)
if flexlove._focusedElement then if flexlove._focusedElement then
@@ -562,6 +785,8 @@ end
---@param scancode string ---@param scancode string
---@param isrepeat boolean ---@param isrepeat boolean
function flexlove.keypressed(key, scancode, isrepeat) function flexlove.keypressed(key, scancode, isrepeat)
-- Handle performance HUD toggle
Performance.keypressed(key)
if flexlove._focusedElement then if flexlove._focusedElement then
flexlove._focusedElement:keypressed(key, scancode, isrepeat) flexlove._focusedElement:keypressed(key, scancode, isrepeat)
end end
@@ -702,6 +927,15 @@ function flexlove.destroy()
flexlove.baseScale = nil flexlove.baseScale = nil
flexlove.scaleFactors = { x = 1.0, y = 1.0 } flexlove.scaleFactors = { x = 1.0, y = 1.0 }
flexlove._cachedViewport = { width = 0, height = 0 } flexlove._cachedViewport = { width = 0, height = 0 }
-- Release canvases explicitly before destroying
if flexlove._gameCanvas then
flexlove._gameCanvas:release()
end
if flexlove._backdropCanvas then
flexlove._backdropCanvas:release()
end
flexlove._gameCanvas = nil flexlove._gameCanvas = nil
flexlove._backdropCanvas = nil flexlove._backdropCanvas = nil
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }

View File

@@ -393,6 +393,65 @@ onEvent = function(element, event)
end end
``` ```
### Deferred Callbacks
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:
```lua
-- In your event handler, queue the callback:
onEvent = function(element, event)
if event.type == "click" then
FlexLove.deferCallback(function()
love.window.setMode(1920, 1080, { fullscreen = true })
end)
end
end
-- In your love.draw(), execute callbacks after ALL canvases are released:
function love.draw()
love.graphics.setCanvas(myCanvas)
FlexLove.draw()
love.graphics.setCanvas() -- Release ALL canvases
-- Execute deferred callbacks now that no canvas is active
FlexLove.executeDeferredCallbacks()
end
```
**IMPORTANT:** You must call `FlexLove.executeDeferredCallbacks()` at the very end of your `love.draw()` function after releasing all canvases. This ensures callbacks execute in a safe context.
#### Automatic Deferral with `onEventDeferred`
Instead of manually wrapping callbacks with `FlexLove.deferCallback()`, you can set the `onEventDeferred` flag to automatically defer all callbacks for that handler:
```lua
FlexLove.Element.new({
width = 200,
height = 50,
text = "Change Resolution",
onEvent = function(element, event)
if event.type == "click" then
-- This will be automatically deferred!
love.window.setMode(1920, 1080, { fullscreen = true })
end
end,
onEventDeferred = true -- Automatically defer all onEvent callbacks
})
```
This flag is available for all event callbacks:
- `onEventDeferred` - Defers `onEvent` callback
- `onFocusDeferred` - Defers `onFocus` callback
- `onBlurDeferred` - Defers `onBlur` callback
- `onTextInputDeferred` - Defers `onTextInput` callback
- `onTextChangeDeferred` - Defers `onTextChange` callback
- `onEnterDeferred` - Defers `onEnter` callback
Deferred callbacks are useful for:
- Changing window mode/resolution
- Loading resources that modify graphics state
- Any operation that conflicts with active canvas rendering
### Input Fields ### Input Fields
FlexLöve provides text input support with single-line (and multi-line coming soon) fields: FlexLöve provides text input support with single-line (and multi-line coming soon) fields:

View File

@@ -280,7 +280,7 @@ cp FlexLove/FlexLove.lua your-project/</code></pre>
<div class="footer"> <div class="footer">
<p> <p>
FlexLöve v0.2.2 | MIT License | FlexLöve v0.2.3 | MIT License |
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff" <a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
>GitHub Repository</a >GitHub Repository</a
> >

View File

@@ -4,6 +4,14 @@ local Blur = {}
local canvasCache = {} local canvasCache = {}
local MAX_CACHE_SIZE = 20 local MAX_CACHE_SIZE = 20
-- Quad cache to avoid recreating quads every frame
local quadCache = {}
local MAX_QUAD_CACHE_SIZE = 20
-- Quad cache to avoid recreating quads every frame
local quadCache = {}
local MAX_QUAD_CACHE_SIZE = 20
--- Build Gaussian blur shader with given parameters --- Build Gaussian blur shader with given parameters
---@param taps number -- Number of samples (must be odd, >= 3) ---@param taps number -- Number of samples (must be odd, >= 3)
---@param offset number ---@param offset number
@@ -105,6 +113,53 @@ local function releaseCanvas(canvas)
end end
end end
--- Get or create a quad from cache
---@param x number
---@param y number
---@param width number
---@param height number
---@param sw number -- Source width
---@param sh number -- Source height
---@return love.Quad
local function getQuad(x, y, width, height, sw, sh)
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not quadCache[key] then
quadCache[key] = {}
end
local cache = quadCache[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.quad
end
end
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true })
if #cache > MAX_QUAD_CACHE_SIZE then
table.remove(cache, 1)
end
return quad
end
--- Release a quad back to the cache
---@param quad love.Quad
local function releaseQuad(quad)
for _, keyCache in pairs(quadCache) do
for _, entry in ipairs(keyCache) do
if entry.quad == quad then
entry.inUse = false
return
end
end
end
end
--- Create a blur effect instance --- Create a blur effect instance
---@param quality number -- Quality level (1-10, higher = better quality but slower) ---@param quality number -- Quality level (1-10, higher = better quality but slower)
---@return table -- Blur effect instance ---@return table -- Blur effect instance
@@ -232,7 +287,7 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
love.graphics.setBlendMode("alpha", "premultiplied") love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions() local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = love.graphics.newQuad(x, y, width, height, backdropWidth, backdropHeight) local quad = getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0) love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(blurInstance.shader) love.graphics.setShader(blurInstance.shader)
@@ -259,11 +314,13 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
releaseCanvas(canvas1) releaseCanvas(canvas1)
releaseCanvas(canvas2) releaseCanvas(canvas2)
releaseQuad(quad)
end end
--- Clear canvas cache (call on window resize) --- Clear canvas cache (call on window resize)
function Blur.clearCache() function Blur.clearCache()
canvasCache = {} canvasCache = {}
quadCache = {}
end end
return Blur return Blur

View File

@@ -40,11 +40,17 @@
---@field transform TransformProps -- Transform properties for animations and styling ---@field transform TransformProps -- Transform properties for animations and styling
---@field transition TransitionProps -- Transition settings for animations ---@field transition TransitionProps -- Transition settings for animations
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events ---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
---@field onFocus fun(element:Element)? -- Callback function when element receives focus ---@field onFocus fun(element:Element)? -- Callback function when element receives focus
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
---@field onBlur fun(element:Element)? -- Callback function when element loses focus ---@field onBlur fun(element:Element)? -- Callback function when element loses focus
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
---@field onTextInput fun(element:Element, text:string)? -- Callback function for text input ---@field onTextInput fun(element:Element, text:string)? -- Callback function for text input
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
---@field onTextChange fun(element:Element, text:string)? -- Callback function when text changes ---@field onTextChange fun(element:Element, text:string)? -- Callback function when text changes
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
---@field onEnter fun(element:Element)? -- Callback function when Enter key is pressed ---@field onEnter fun(element:Element)? -- Callback function when Enter key is pressed
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
---@field units table -- Original unit specifications for responsive behavior ---@field units table -- Original unit specifications for responsive behavior
---@field _eventHandler EventHandler -- Event handler instance for input processing ---@field _eventHandler EventHandler -- Event handler instance for input processing
---@field _explicitlyAbsolute boolean? ---@field _explicitlyAbsolute boolean?
@@ -215,7 +221,10 @@ function Element.new(props, deps)
self._stateId = self.id self._stateId = self.id
-- In immediate mode, restore EventHandler state from StateManager -- In immediate mode, restore EventHandler state from StateManager
local eventHandlerConfig = { onEvent = self.onEvent } local eventHandlerConfig = {
onEvent = self.onEvent,
onEventDeferred = props.onEventDeferred
}
if self._deps.Context._immediateMode and self._stateId and self._stateId ~= "" then if self._deps.Context._immediateMode and self._stateId and self._stateId ~= "" then
local state = self._deps.StateManager.getState(self._stateId) local state = self._deps.StateManager.getState(self._stateId)
if state then if state then
@@ -1769,6 +1778,11 @@ function Element:applyPositioningOffsets(element)
end end
function Element:layoutChildren() function Element:layoutChildren()
-- Check performance warnings (only on root elements to avoid spam)
if not self.parent then
self:_checkPerformanceWarnings()
end
-- Delegate layout to LayoutEngine -- Delegate layout to LayoutEngine
self._layoutEngine:layoutChildren() self._layoutEngine:layoutChildren()
end end
@@ -1976,6 +1990,11 @@ end
--- Update element (propagate to children) --- Update element (propagate to children)
---@param dt number ---@param dt number
function Element:update(dt) function Element:update(dt)
-- Track active animations for performance warnings (only on root elements)
if not self.parent then
self:_trackActiveAnimations()
end
-- Restore scrollbar state from StateManager in immediate mode -- Restore scrollbar state from StateManager in immediate mode
if self._stateId and self._deps.Context._immediateMode then if self._stateId and self._deps.Context._immediateMode then
local state = self._deps.StateManager.getState(self._stateId) local state = self._deps.StateManager.getState(self._stateId)
@@ -2632,4 +2651,94 @@ function Element:keypressed(key, scancode, isrepeat)
end end
end end
-- ====================
-- Performance Monitoring
-- ====================
--- Get hierarchy depth of this element
---@return number depth Depth in the element tree (0 for root)
function Element:getHierarchyDepth()
local depth = 0
local current = self.parent
while current do
depth = depth + 1
current = current.parent
end
return depth
end
--- Count total elements in this tree
---@return number count Total number of elements including this one and all descendants
function Element:countElements()
local count = 1 -- Count self
for _, child in ipairs(self.children) do
count = count + child:countElements()
end
return count
end
--- Check and warn about performance issues in element hierarchy
function Element:_checkPerformanceWarnings()
-- Check if performance warnings are enabled
local Performance = self._deps and (package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"])
if not Performance or not Performance.areWarningsEnabled() then
return
end
-- Check hierarchy depth
local depth = self:getHierarchyDepth()
if depth >= 15 then
Performance.logWarning(
string.format("hierarchy_depth_%s", self.id),
"Element",
string.format("Element hierarchy depth is %d levels for element '%s'", depth, self.id or "unnamed"),
{ depth = depth, elementId = self.id or "unnamed" },
"Deep nesting can impact performance. Consider flattening the structure or using absolute positioning"
)
end
-- Check total element count (only for root elements)
if not self.parent then
local totalElements = self:countElements()
if totalElements >= 1000 then
Performance.logWarning(
"element_count_high",
"Element",
string.format("UI contains %d+ elements", totalElements),
{ elementCount = totalElements },
"Large element counts may impact performance. Consider virtualization for long lists or pagination for large datasets"
)
end
end
end
--- Count active animations in tree
---@return number count Number of active animations
function Element:_countActiveAnimations()
local count = self.animation and 1 or 0
for _, child in ipairs(self.children) do
count = count + child:_countActiveAnimations()
end
return count
end
--- Track active animations and warn if too many
function Element:_trackActiveAnimations()
local Performance = self._deps and (package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"])
if not Performance or not Performance.areWarningsEnabled() then
return
end
local animCount = self:_countActiveAnimations()
if animCount >= 50 then
Performance.logWarning(
"animation_count_high",
"Element",
string.format("%d+ animations running simultaneously", animCount),
{ animationCount = animCount },
"High animation counts may impact frame rate. Consider reducing concurrent animations or using CSS-style transitions"
)
end
end
return Element return Element

View File

@@ -279,6 +279,36 @@ ErrorCodes.codes = {
description = "Module initialization failed", description = "Module initialization failed",
suggestion = "Check module dependencies and initialization order", suggestion = "Check module dependencies and initialization order",
}, },
-- Performance Warnings (PERF_001 - PERF_099)
PERF_001 = {
code = "FLEXLOVE_PERF_001",
category = "PERF",
description = "Performance threshold exceeded",
suggestion = "Operation took longer than recommended. Monitor for patterns.",
},
PERF_002 = {
code = "FLEXLOVE_PERF_002",
category = "PERF",
description = "Critical performance threshold exceeded",
suggestion = "Operation is causing frame drops. Consider optimizing or reducing frequency.",
},
-- Memory Warnings (MEM_001 - MEM_099)
MEM_001 = {
code = "FLEXLOVE_MEM_001",
category = "MEM",
description = "Memory leak detected",
suggestion = "Table is growing consistently. Review cache eviction policies and ensure objects are properly released.",
},
-- State Management Warnings (STATE_001 - STATE_099)
STATE_001 = {
code = "FLEXLOVE_STATE_001",
category = "STATE",
description = "CallSite counters accumulating",
suggestion = "This indicates incrementFrame() may not be called properly. Check immediate mode frame management.",
},
} }
--- Get error information by code --- Get error information by code

View File

@@ -499,11 +499,8 @@ function ErrorHandler.warn(module, codeOrMessage, messageOrDetails, detailsOrSug
end end
end end
-- Log the warning -- Log the warning (writeLog handles console output based on config.logTarget)
writeLog("WARNING", LOG_LEVELS.WARNING, module, code, message, details, logSuggestion) writeLog("WARNING", LOG_LEVELS.WARNING, module, code, message, details, logSuggestion)
local formattedMessage = formatMessage(module, "Warning", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
print(formattedMessage)
end end
--- Validate that a value is not nil --- Validate that a value is not nil

View File

@@ -1,5 +1,6 @@
---@class EventHandler ---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)? ---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field _pressed table<number, boolean> ---@field _pressed table<number, boolean>
---@field _lastClickTime number? ---@field _lastClickTime number?
---@field _lastClickButton number? ---@field _lastClickButton number?
@@ -32,6 +33,7 @@ function EventHandler.new(config, deps)
self._utils = deps.utils self._utils = deps.utils
self.onEvent = config.onEvent self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self._pressed = config._pressed or {} self._pressed = config._pressed or {}
@@ -101,7 +103,16 @@ end
---@param isHovering boolean Whether mouse is over element ---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position ---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement) function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
-- Start performance timing
local Performance = package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"]
if Performance and Performance.isEnabled() then
Performance.startTimer("event_mouse")
end
if not self._element then if not self._element then
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_mouse")
end
return return
end end
@@ -140,6 +151,9 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
end end
end end
end end
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_mouse")
end
return return
end end
@@ -190,6 +204,11 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
end end
end end
end end
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("event_mouse")
end
end end
--- Handle mouse button press --- Handle mouse button press
@@ -214,7 +233,6 @@ function EventHandler:_handleMousePress(mx, my, button)
end end
-- Fire press event -- Fire press event
if self.onEvent then
local modifiers = self._utils.getModifiers() local modifiers = self._utils.getModifiers()
local pressEvent = self._InputEvent.new({ local pressEvent = self._InputEvent.new({
type = "press", type = "press",
@@ -224,8 +242,7 @@ function EventHandler:_handleMousePress(mx, my, button)
modifiers = modifiers, modifiers = modifiers,
clickCount = 1, clickCount = 1,
}) })
self.onEvent(element, pressEvent) self:_invokeCallback(element, pressEvent)
end
self._pressed[button] = true self._pressed[button] = true
@@ -259,7 +276,7 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
if lastX ~= mx or lastY ~= my then if lastX ~= mx or lastY ~= my then
-- Mouse has moved - fire drag event only if still hovering -- Mouse has moved - fire drag event only if still hovering
if self.onEvent and isHovering then if isHovering then
local modifiers = self._utils.getModifiers() local modifiers = self._utils.getModifiers()
local dx = mx - self._dragStartX[button] local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button] local dy = my - self._dragStartY[button]
@@ -274,7 +291,7 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
modifiers = modifiers, modifiers = modifiers,
clickCount = 1, clickCount = 1,
}) })
self.onEvent(element, dragEvent) self:_invokeCallback(element, dragEvent)
end end
-- Handle text selection drag for editable elements -- Handle text selection drag for editable elements
@@ -325,7 +342,6 @@ function EventHandler:_handleMouseRelease(mx, my, button)
end end
-- Fire click event -- Fire click event
if self.onEvent then
local clickEvent = self._InputEvent.new({ local clickEvent = self._InputEvent.new({
type = eventType, type = eventType,
button = button, button = button,
@@ -334,8 +350,7 @@ function EventHandler:_handleMouseRelease(mx, my, button)
modifiers = modifiers, modifiers = modifiers,
clickCount = clickCount, clickCount = clickCount,
}) })
self.onEvent(element, clickEvent) self:_invokeCallback(element, clickEvent)
end
self._pressed[button] = false self._pressed[button] = false
@@ -367,7 +382,6 @@ function EventHandler:_handleMouseRelease(mx, my, button)
end end
-- Fire release event -- Fire release event
if self.onEvent then
local releaseEvent = self._InputEvent.new({ local releaseEvent = self._InputEvent.new({
type = "release", type = "release",
button = button, button = button,
@@ -376,13 +390,12 @@ function EventHandler:_handleMouseRelease(mx, my, button)
modifiers = modifiers, modifiers = modifiers,
clickCount = clickCount, clickCount = clickCount,
}) })
self.onEvent(element, releaseEvent) self:_invokeCallback(element, releaseEvent)
end
end end
--- Process touch events in the update cycle --- Process touch events in the update cycle
function EventHandler:processTouchEvents() function EventHandler:processTouchEvents()
if not self._element or not self.onEvent then if not self._element then
return return
end end
@@ -408,7 +421,7 @@ function EventHandler:processTouchEvents()
modifiers = self._utils.getModifiers(), modifiers = self._utils.getModifiers(),
clickCount = 1, clickCount = 1,
}) })
self.onEvent(element, touchEvent) self:_invokeCallback(element, touchEvent)
self._touchPressed[id] = false self._touchPressed[id] = false
end end
end end
@@ -437,4 +450,29 @@ function EventHandler:isButtonPressed(button)
return self._pressed[button] == true return self._pressed[button] == true
end end
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
---@param element Element The element that triggered the event
---@param event InputEvent The event data
function EventHandler:_invokeCallback(element, event)
if not self.onEvent then
return
end
if self.onEventDeferred then
-- Get FlexLove module to defer the callback
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onEvent(element, event)
end)
else
-- Fallback: execute immediately if FlexLove not available
self.onEvent(element, event)
end
else
-- Execute immediately
self.onEvent(element, event)
end
end
return EventHandler return EventHandler

View File

@@ -21,6 +21,8 @@
---@field _AlignItems table ---@field _AlignItems table
---@field _AlignSelf table ---@field _AlignSelf table
---@field _FlexWrap table ---@field _FlexWrap table
---@field _layoutCount number Track layout recalculations per frame
---@field _lastFrameCount number Last frame number for resetting counters
local LayoutEngine = {} local LayoutEngine = {}
LayoutEngine.__index = LayoutEngine LayoutEngine.__index = LayoutEngine
@@ -84,6 +86,10 @@ function LayoutEngine.new(props, deps)
-- Element reference (will be set via initialize) -- Element reference (will be set via initialize)
self.element = nil self.element = nil
-- Performance tracking
self._layoutCount = 0
self._lastFrameCount = 0
return self return self
end end
@@ -146,6 +152,16 @@ function LayoutEngine:layoutChildren()
return return
end end
-- Start performance timing
local Performance = package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"]
if Performance and Performance.isEnabled() then
local elementId = self.element.id or "unnamed"
Performance.startTimer("layout_" .. elementId)
end
-- Track layout recalculations for performance warnings
self:_trackLayoutRecalculation()
if self.positioning == self._Positioning.ABSOLUTE or self.positioning == self._Positioning.RELATIVE then if self.positioning == self._Positioning.ABSOLUTE or self.positioning == self._Positioning.RELATIVE then
-- Absolute/Relative positioned containers don't layout their children according to flex rules, -- Absolute/Relative positioned containers don't layout their children according to flex rules,
-- but they should still apply CSS positioning offsets to their children -- but they should still apply CSS positioning offsets to their children
@@ -159,18 +175,32 @@ function LayoutEngine:layoutChildren()
if self.element._detectOverflow then if self.element._detectOverflow then
self.element:_detectOverflow() self.element:_detectOverflow()
end end
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
end
return return
end end
-- Handle grid layout -- Handle grid layout
if self.positioning == self._Positioning.GRID then if self.positioning == self._Positioning.GRID then
self._Grid.layoutGridItems(self.element) self._Grid.layoutGridItems(self.element)
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
end
return return
end end
local childCount = #self.element.children local childCount = #self.element.children
if childCount == 0 then if childCount == 0 then
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
end
return return
end end
@@ -569,6 +599,11 @@ function LayoutEngine:layoutChildren()
if self.element._detectOverflow then if self.element._detectOverflow then
self.element:_detectOverflow() self.element:_detectOverflow()
end end
-- Stop performance timing
if Performance and Performance.isEnabled() then
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
end
end end
--- Calculate auto width based on children --- Calculate auto width based on children
@@ -940,4 +975,37 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end end
end end
--- Track layout recalculations and warn about excessive layouts
function LayoutEngine:_trackLayoutRecalculation()
-- Get Performance module if available
local Performance = package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"]
if not Performance or not Performance.areWarningsEnabled() then
return
end
-- Get current frame count from Context
local currentFrame = self._Context and self._Context._frameNumber or 0
-- Reset counter on new frame
if currentFrame ~= self._lastFrameCount then
self._lastFrameCount = currentFrame
self._layoutCount = 0
end
-- Increment layout count
self._layoutCount = self._layoutCount + 1
-- Warn if layout is recalculated excessively this frame
if self._layoutCount >= 10 then
local elementId = self.element and self.element.id or "unnamed"
Performance.logWarning(
string.format("excessive_layout_%s", elementId),
"LayoutEngine",
string.format("Layout recalculated %d times this frame for element '%s'", self._layoutCount, elementId),
{ layoutCount = self._layoutCount, elementId = elementId },
"This may indicate a layout thrashing issue. Check for circular dependencies or dynamic sizing that triggers re-layout"
)
end
end
return LayoutEngine return LayoutEngine

View File

@@ -3,20 +3,52 @@
---@class Performance ---@class Performance
local Performance = {} local Performance = {}
-- Load ErrorHandler (with fallback if not available)
local ErrorHandler = nil
local ErrorHandlerInitialized = false
local function getErrorHandler()
if not ErrorHandler then
local success, module = pcall(require, "modules.ErrorHandler")
if success then
ErrorHandler = module
-- Initialize ErrorHandler with ErrorCodes if not already initialized
if not ErrorHandlerInitialized then
local successCodes, ErrorCodes = pcall(require, "modules.ErrorCodes")
if successCodes and ErrorHandler.init then
ErrorHandler.init({ErrorCodes = ErrorCodes})
end
ErrorHandlerInitialized = true
end
end
end
return ErrorHandler
end
-- Configuration -- Configuration
local config = { local config = {
enabled = false, enabled = false,
hudEnabled = false, hudEnabled = false,
hudToggleKey = "f3", hudToggleKey = "f3",
hudPosition = { x = 10, y = 10 },
warningThresholdMs = 13.0, -- Yellow warning warningThresholdMs = 13.0, -- Yellow warning
criticalThresholdMs = 16.67, -- Red warning (60 FPS) criticalThresholdMs = 16.67, -- Red warning (60 FPS)
logToConsole = false, logToConsole = false,
logWarnings = true, logWarnings = true,
warningsEnabled = true,
} }
-- Metrics cleanup configuration
local METRICS_CLEANUP_INTERVAL = 30 -- Cleanup every 30 seconds (more aggressive)
local METRICS_RETENTION_TIME = 10 -- Keep metrics used in last 10 seconds
local MAX_METRICS_COUNT = 500 -- Maximum number of unique metrics
local CORE_METRICS = { frame = true, layout = true, render = true } -- Never cleanup these
-- State -- State
local timers = {} -- Active timers {name -> startTime} local timers = {} -- Active timers {name -> startTime}
local metrics = {} -- Accumulated metrics {name -> {total, count, min, max}} local metrics = {} -- Accumulated metrics {name -> {total, count, min, max, lastUsed}}
local lastMetricsCleanup = 0 -- Last time metrics were cleaned up
local frameMetrics = { local frameMetrics = {
frameCount = 0, frameCount = 0,
totalTime = 0, totalTime = 0,
@@ -35,6 +67,17 @@ local memoryMetrics = {
} }
local warnings = {} local warnings = {}
local lastFrameStart = nil local lastFrameStart = nil
local shownWarnings = {} -- Track warnings that have been shown (dedupe)
-- Memory profiling state
local memoryProfiler = {
enabled = false,
sampleInterval = 60, -- Frames between samples
framesSinceLastSample = 0,
samples = {}, -- Array of {time, memory, tableSizes}
maxSamples = 20, -- Keep last 20 samples (~20 seconds at 60fps)
monitoredTables = {}, -- Tables to monitor (added via registerTable)
}
--- Initialize performance monitoring --- Initialize performance monitoring
--- @param options table? Optional configuration overrides --- @param options table? Optional configuration overrides
@@ -73,6 +116,7 @@ function Performance.reset()
timers = {} timers = {}
metrics = {} metrics = {}
warnings = {} warnings = {}
shownWarnings = {}
frameMetrics.frameCount = 0 frameMetrics.frameCount = 0
frameMetrics.totalTime = 0 frameMetrics.totalTime = 0
frameMetrics.lastFrameTime = 0 frameMetrics.lastFrameTime = 0
@@ -119,6 +163,7 @@ function Performance.stopTimer(name)
min = math.huge, min = math.huge,
max = 0, max = 0,
average = 0, average = 0,
lastUsed = love.timer.getTime(),
} }
end end
@@ -128,6 +173,7 @@ function Performance.stopTimer(name)
m.min = math.min(m.min, elapsed) m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed) m.max = math.max(m.max, elapsed)
m.average = m.total / m.count m.average = m.total / m.count
m.lastUsed = love.timer.getTime()
-- Check for warnings -- Check for warnings
if elapsed > config.criticalThresholdMs then if elapsed > config.criticalThresholdMs then
@@ -160,6 +206,24 @@ function Performance.measure(name, fn)
end end
end end
--- Update with actual delta time from LÖVE (call from love.update)
---@param dt number Delta time in seconds
function Performance.updateDeltaTime(dt)
if not config.enabled then
return
end
local now = love.timer.getTime()
-- Update FPS from actual delta time (not processing time)
if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then
if dt > 0 then
frameMetrics.fps = math.floor(1 / dt + 0.5)
end
frameMetrics.lastFpsUpdate = now
end
end
--- Start frame timing (call at beginning of frame) --- Start frame timing (call at beginning of frame)
function Performance.startFrame() function Performance.startFrame()
if not config.enabled then if not config.enabled then
@@ -184,16 +248,54 @@ function Performance.endFrame()
frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime) frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime)
frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime) frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime)
-- Update FPS -- Note: FPS is now calculated from actual delta time in updateDeltaTime()
if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then -- frameTime here represents processing time, not actual frame rate
frameMetrics.fps = math.floor(1000 / frameTime + 0.5)
frameMetrics.lastFpsUpdate = now
end
-- Check for frame drops -- Check for frame drops
if frameTime > config.criticalThresholdMs then if frameTime > config.criticalThresholdMs then
Performance.addWarning("frame", frameTime, "critical") Performance.addWarning("frame", frameTime, "critical")
end end
-- Update memory profiling
Performance.updateMemoryProfiling()
-- Periodic metrics cleanup (every 30 seconds, more aggressive)
if now - lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
local cleanupTime = now - METRICS_RETENTION_TIME
for name, data in pairs(metrics) do
-- Don't cleanup core metrics
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
metrics[name] = nil
end
end
lastMetricsCleanup = now
end
-- Enforce max metrics limit
local metricsCount = 0
for _ in pairs(metrics) do
metricsCount = metricsCount + 1
end
if metricsCount > MAX_METRICS_COUNT then
-- Find and remove oldest non-core metrics
local sortedMetrics = {}
for name, data in pairs(metrics) do
if not CORE_METRICS[name] then
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
end
end
table.sort(sortedMetrics, function(a, b)
return a.lastUsed < b.lastUsed
end)
-- Remove oldest metrics until we're under the limit
local toRemove = metricsCount - MAX_METRICS_COUNT
for i = 1, math.min(toRemove, #sortedMetrics) do
metrics[sortedMetrics[i].name] = nil
end
end
end end
--- Update memory metrics --- Update memory metrics
@@ -223,17 +325,54 @@ function Performance.addWarning(name, value, level)
return return
end end
table.insert(warnings, { local warning = {
name = name, name = name,
value = value, value = value,
level = level, level = level,
time = love.timer.getTime(), time = love.timer.getTime(),
}) }
table.insert(warnings, warning)
-- Keep only last 100 warnings -- Keep only last 100 warnings
if #warnings > 100 then if #warnings > 100 then
table.remove(warnings, 1) table.remove(warnings, 1)
end end
-- Log to console if enabled (with deduplication per metric name)
if config.logToConsole or config.warningsEnabled then
local warningKey = name .. "_" .. level
-- Only log each warning type once per 60 seconds to avoid spam
local lastWarningTime = shownWarnings[warningKey] or 0
local now = love.timer.getTime()
if now - lastWarningTime >= 60 then
-- Route through ErrorHandler for consistent logging
local EH = getErrorHandler()
if EH and EH.warn then
local message = string.format("%s = %.2fms", name, value)
local code = level == "critical" and "PERF_002" or "PERF_001"
local suggestion = level == "critical"
and "This operation is causing frame drops. Consider optimizing or reducing frequency."
or "This operation is taking longer than recommended. Monitor for patterns."
EH.warn("Performance", code, message, {
metric = name,
value = string.format("%.2fms", value),
threshold = level == "critical" and config.criticalThresholdMs or config.warningThresholdMs,
}, suggestion)
else
-- Fallback to direct print if ErrorHandler not available
local prefix = level == "critical" and "[CRITICAL]" or "[WARNING]"
local message = string.format("%s Performance: %s = %.2fms", prefix, name, value)
print(message)
end
shownWarnings[warningKey] = now
end
end
end end
--- Get current FPS --- Get current FPS
@@ -346,15 +485,16 @@ function Performance.renderHUD(x, y)
return return
end end
x = x or 10 -- Use config position if x/y not provided
y = y or 10 x = x or config.hudPosition.x
y = y or config.hudPosition.y
local fm = Performance.getFrameMetrics() local fm = Performance.getFrameMetrics()
local mm = Performance.getMemoryMetrics() local mm = Performance.getMemoryMetrics()
-- Background -- Background
love.graphics.setColor(0, 0, 0, 0.8) love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 200) love.graphics.rectangle("fill", x, y, 300, 220)
-- Text -- Text
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -383,7 +523,18 @@ function Performance.renderHUD(x, y)
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY) love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY)
currentY = currentY + lineHeight currentY = currentY + lineHeight
-- Metrics count
local metricsCount = 0
for _ in pairs(metrics) do
metricsCount = metricsCount + 1
end
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
love.graphics.setColor(metricsColor)
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
currentY = currentY + lineHeight
-- Separator -- Separator
love.graphics.setColor(1, 1, 1, 1)
currentY = currentY + 5 currentY = currentY + 5
-- Top timings -- Top timings
@@ -432,4 +583,259 @@ function Performance.setConfig(key, value)
config[key] = value config[key] = value
end end
--- Check if performance warnings are enabled
--- @return boolean enabled True if warnings are enabled
function Performance.areWarningsEnabled()
return config.warningsEnabled
end
--- Log a performance warning (only once per warning key)
--- @param warningKey string Unique key for this warning type
--- @param module string Module name (e.g., "LayoutEngine", "Element")
--- @param message string Warning message
--- @param details table? Additional details
--- @param suggestion string? Optimization suggestion
function Performance.logWarning(warningKey, module, message, details, suggestion)
if not config.warningsEnabled then
return
end
-- Only show each warning once per session
if shownWarnings[warningKey] then
return
end
shownWarnings[warningKey] = true
-- Limit shownWarnings size to prevent memory leak (keep last 1000 unique warnings)
local count = 0
for _ in pairs(shownWarnings) do
count = count + 1
end
if count > 1000 then
-- Reset when limit exceeded (simple approach - could be more sophisticated)
shownWarnings = { [warningKey] = true }
end
-- Use ErrorHandler if available
local EH = getErrorHandler()
if EH and EH.warn then
EH.warn(module, "PERF_001", message, details or {}, suggestion)
else
-- Fallback to print
print(string.format("[FlexLove - %s] Performance Warning: %s", module, message))
if suggestion then
print(string.format(" Suggestion: %s", suggestion))
end
end
end
--- Reset shown warnings (useful for testing or session restart)
function Performance.resetShownWarnings()
shownWarnings = {}
end
--- Track a counter metric (increments per frame)
--- @param name string Counter name
--- @param value number? Value to add (default: 1)
function Performance.incrementCounter(name, value)
if not config.enabled then
return
end
value = value or 1
if not metrics[name] then
metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
frameValue = 0, -- Current frame value
lastUsed = love.timer.getTime(),
}
end
local m = metrics[name]
m.frameValue = (m.frameValue or 0) + value
m.lastUsed = love.timer.getTime()
end
--- Reset frame counters (call at end of frame)
function Performance.resetFrameCounters()
if not config.enabled then
return
end
local now = love.timer.getTime()
local toRemove = {}
for name, data in pairs(metrics) do
if data.frameValue then
-- Update statistics only if value is non-zero
if data.frameValue > 0 then
data.total = data.total + data.frameValue
data.count = data.count + 1
data.min = math.min(data.min, data.frameValue)
data.max = math.max(data.max, data.frameValue)
data.average = data.total / data.count
data.lastUsed = now
end
-- Reset frame value
data.frameValue = 0
-- Mark zero-count metrics for removal (non-core)
if data.count == 0 and not CORE_METRICS[name] then
table.insert(toRemove, name)
end
end
end
-- Remove zero-value counters
for _, name in ipairs(toRemove) do
metrics[name] = nil
end
end
--- Get current frame counter value
--- @param name string Counter name
--- @return number value Current frame value
function Performance.getFrameCounter(name)
if not config.enabled or not metrics[name] then
return 0
end
return metrics[name].frameValue or 0
end
-- ====================
-- Memory Profiling
-- ====================
--- Enable memory profiling
function Performance.enableMemoryProfiling()
memoryProfiler.enabled = true
end
--- Disable memory profiling
function Performance.disableMemoryProfiling()
memoryProfiler.enabled = false
end
--- Register a table for memory leak monitoring
--- @param name string Friendly name for the table
--- @param tableRef table Reference to the table to monitor
function Performance.registerTableForMonitoring(name, tableRef)
memoryProfiler.monitoredTables[name] = tableRef
end
--- Get table size (number of entries)
--- @param tbl table Table to measure
--- @return number count Number of entries
local function getTableSize(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
--- Sample memory and table sizes
local function sampleMemory()
local sample = {
time = love.timer.getTime(),
memory = collectgarbage("count") / 1024, -- MB
tableSizes = {},
}
for name, tableRef in pairs(memoryProfiler.monitoredTables) do
sample.tableSizes[name] = getTableSize(tableRef)
end
table.insert(memoryProfiler.samples, sample)
-- Keep only maxSamples
if #memoryProfiler.samples > memoryProfiler.maxSamples then
table.remove(memoryProfiler.samples, 1)
end
-- Check for memory leaks (consistent growth)
if #memoryProfiler.samples >= 5 then
for name, _ in pairs(memoryProfiler.monitoredTables) do
local sizes = {}
for i = math.max(1, #memoryProfiler.samples - 4), #memoryProfiler.samples do
table.insert(sizes, memoryProfiler.samples[i].tableSizes[name])
end
-- Check if table is consistently growing
local growing = true
for i = 2, #sizes do
if sizes[i] <= sizes[i - 1] then
growing = false
break
end
end
if growing and sizes[#sizes] > sizes[1] * 1.5 then
Performance.addWarning("memory_leak", sizes[#sizes], "warning")
if not shownWarnings[name] then
-- Route through ErrorHandler for consistent logging
local EH = getErrorHandler()
if EH and EH.warn then
local message = string.format("Table '%s' growing consistently", name)
EH.warn("Performance", "MEM_001", message, {
table = name,
initialSize = sizes[1],
currentSize = sizes[#sizes],
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
}, "Check for memory leaks in this table. Review cache eviction policies and ensure objects are properly released.")
else
-- Fallback to direct print
print(string.format("[FlexLove] MEMORY LEAK WARNING: Table '%s' growing consistently (%d -> %d)", name, sizes[1], sizes[#sizes]))
end
shownWarnings[name] = true
end
elseif not growing then
-- Reset warning flag if table stopped growing
shownWarnings[name] = nil
end
end
end
end
--- Update memory profiling (call from endFrame)
function Performance.updateMemoryProfiling()
if not memoryProfiler.enabled then
return
end
memoryProfiler.framesSinceLastSample = memoryProfiler.framesSinceLastSample + 1
if memoryProfiler.framesSinceLastSample >= memoryProfiler.sampleInterval then
sampleMemory()
memoryProfiler.framesSinceLastSample = 0
end
end
--- Get memory profiling data
--- @return table profile {samples, monitoredTables}
function Performance.getMemoryProfile()
return {
samples = memoryProfiler.samples,
monitoredTables = {},
enabled = memoryProfiler.enabled,
}
end
--- Reset memory profiling data
function Performance.resetMemoryProfile()
memoryProfiler.samples = {}
memoryProfiler.framesSinceLastSample = 0
shownWarnings = {}
end
return Performance return Performance

View File

@@ -305,8 +305,20 @@ end
--- Main draw method - renders all visual layers --- Main draw method - renders all visual layers
---@param backdropCanvas table|nil Backdrop canvas for backdrop blur ---@param backdropCanvas table|nil Backdrop canvas for backdrop blur
function Renderer:draw(backdropCanvas) function Renderer:draw(backdropCanvas)
-- Start performance timing
local Performance = package.loaded["modules.Performance"] or package.loaded["libs.modules.Performance"]
local elementId
if Performance and Performance.isEnabled() and self._element then
elementId = self._element.id or "unnamed"
Performance.startTimer("render_" .. elementId)
Performance.incrementCounter("draw_calls", 1)
end
-- Early exit if element is invisible (optimization) -- Early exit if element is invisible (optimization)
if self.opacity <= 0 then if self.opacity <= 0 then
if Performance and Performance.isEnabled() and elementId then
Performance.stopTimer("render_" .. elementId)
end
return return
end end
@@ -354,6 +366,11 @@ function Renderer:draw(backdropCanvas)
-- LAYER 3: Draw borders on top of theme -- LAYER 3: Draw borders on top of theme
self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight) self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight)
-- Stop performance timing
if Performance and Performance.isEnabled() and elementId then
Performance.stopTimer("render_" .. elementId)
end
end end
--- Get font for element (resolves from theme or fontFamily) --- Get font for element (resolves from theme or fontFamily)

View File

@@ -470,7 +470,7 @@ function StateManager.configure(newConfig)
end end
--- Get state statistics for debugging --- Get state statistics for debugging
---@return {stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil} ---@return table stats State usage statistics
function StateManager.getStats() function StateManager.getStats()
local stateCount = StateManager.getStateCount() local stateCount = StateManager.getStateCount()
local oldest = nil local oldest = nil
@@ -485,11 +485,32 @@ function StateManager.getStats()
end end
end end
-- Count callSiteCounters
local callSiteCount = 0
for _ in pairs(callSiteCounters) do
callSiteCount = callSiteCount + 1
end
-- Warn if callSiteCounters is unexpectedly large
if callSiteCount > 1000 then
if ErrorHandler then
local message = string.format("callSiteCounters has %d entries (expected near 0 per frame)", callSiteCount)
ErrorHandler.warn("StateManager", "STATE_001", message, {
count = callSiteCount,
expected = "near 0",
frameNumber = frameNumber,
}, "This indicates incrementFrame() may not be called properly or counters aren't being reset. Check immediate mode frame management.")
else
print(string.format("[StateManager] WARNING: callSiteCounters has %d entries", callSiteCount))
end
end
return { return {
stateCount = stateCount, stateCount = stateCount,
frameNumber = frameNumber, frameNumber = frameNumber,
oldestState = oldest, oldestState = oldest,
newestState = newest, newestState = newest,
callSiteCounterCount = callSiteCount,
} }
end end
@@ -508,6 +529,16 @@ function StateManager.dumpStates()
return dump return dump
end end
--- Get internal state (for debugging/profiling only)
---@return table internal {stateStore, stateMetadata, callSiteCounters}
function StateManager._getInternalState()
return {
stateStore = stateStore,
stateMetadata = stateMetadata,
callSiteCounters = callSiteCounters,
}
end
--- Reset the entire state system (for testing) --- Reset the entire state system (for testing)
function StateManager.reset() function StateManager.reset()
stateStore = {} stateStore = {}

View File

@@ -139,39 +139,94 @@ local function resolveImagePath(path)
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
end end
-- Font cache with LRU eviction
local FONT_CACHE = {} local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50 local FONT_CACHE_MAX_SIZE = 50
local FONT_CACHE_ORDER = {} local FONT_CACHE_STATS = {
hits = 0,
misses = 0,
evictions = 0,
size = 0,
}
-- LRU tracking: each entry has {font, lastUsed, accessCount}
local function updateCacheAccess(cacheKey)
local entry = FONT_CACHE[cacheKey]
if entry then
entry.lastUsed = love.timer.getTime()
entry.accessCount = entry.accessCount + 1
end
end
local function evictLRU()
local oldestKey = nil
local oldestTime = math.huge
for key, entry in pairs(FONT_CACHE) do
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
if type(entry) == "table" and entry.lastUsed then
if entry.lastUsed < oldestTime then
oldestTime = entry.lastUsed
oldestKey = key
end
end
end
if oldestKey then
FONT_CACHE[oldestKey] = nil
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
end
end
--- Create or get a font from cache --- Create or get a font from cache
---@param size number ---@param size number
---@param fontPath string? ---@param fontPath string?
---@return love.Font ---@return love.Font
function FONT_CACHE.get(size, fontPath) function FONT_CACHE.get(size, fontPath)
local cacheKey = fontPath and (fontPath .. "_" .. tostring(size)) or tostring(size) -- Round size to reduce cache entries (e.g., 14.5 -> 15, 14.7 -> 15)
size = math.floor(size + 0.5)
if not FONT_CACHE[cacheKey] then local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if FONT_CACHE[cacheKey] then
-- Cache hit
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
updateCacheAccess(cacheKey)
return FONT_CACHE[cacheKey].font
end
-- Cache miss
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
local font
if fontPath then if fontPath then
local resolvedPath = resolveImagePath(fontPath) local resolvedPath = resolveImagePath(fontPath)
local success, font = pcall(love.graphics.newFont, resolvedPath, size) local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then if success then
FONT_CACHE[cacheKey] = font font = result
else else
print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font") print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font")
FONT_CACHE[cacheKey] = love.graphics.newFont(size) font = love.graphics.newFont(size)
end end
else else
FONT_CACHE[cacheKey] = love.graphics.newFont(size) font = love.graphics.newFont(size)
end end
table.insert(FONT_CACHE_ORDER, cacheKey) -- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
if #FONT_CACHE_ORDER > FONT_CACHE_MAX_SIZE then -- Evict if cache is full
local oldestKey = table.remove(FONT_CACHE_ORDER, 1) if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
FONT_CACHE[oldestKey] = nil evictLRU()
end end
end
return FONT_CACHE[cacheKey] return font
end end
--- Get font for text size (cached) --- Get font for text size (cached)
@@ -948,6 +1003,92 @@ local function validateDimension(value)
}) })
end end
-- Font cache management
--- Get font cache statistics
---@return table stats {hits, misses, evictions, size, hitRate}
local function getFontCacheStats()
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
return {
hits = FONT_CACHE_STATS.hits,
misses = FONT_CACHE_STATS.misses,
evictions = FONT_CACHE_STATS.evictions,
size = FONT_CACHE_STATS.size,
hitRate = hitRate,
}
end
--- Set maximum font cache size
---@param maxSize number Maximum number of fonts to cache
local function setFontCacheSize(maxSize)
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
-- Evict entries if cache is now over limit
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
evictLRU()
end
end
--- Clear font cache
local function clearFontCache()
-- Clear cache entries but preserve methods (get, getFont)
for key, entry in pairs(FONT_CACHE) do
if type(entry) == "table" and entry.lastUsed then
FONT_CACHE[key] = nil
end
end
FONT_CACHE_STATS.size = 0
FONT_CACHE_STATS.evictions = 0
end
--- Preload font at multiple sizes
---@param fontPath string? Path to font file (nil for default font)
---@param sizes table Array of font sizes to preload
local function preloadFont(fontPath, sizes)
for _, size in ipairs(sizes) do
-- Round size to reduce cache entries
size = math.floor(size + 0.5)
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if not FONT_CACHE[cacheKey] then
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
end
end
end
--- Reset font cache statistics
local function resetFontCacheStats()
FONT_CACHE_STATS.hits = 0
FONT_CACHE_STATS.misses = 0
FONT_CACHE_STATS.evictions = 0
end
return { return {
enums = enums, enums = enums,
FONT_CACHE = FONT_CACHE, FONT_CACHE = FONT_CACHE,
@@ -981,6 +1122,12 @@ return {
validatePath = validatePath, validatePath = validatePath,
getFileExtension = getFileExtension, getFileExtension = getFileExtension,
hasAllowedExtension = hasAllowedExtension, hasAllowedExtension = hasAllowedExtension,
-- Font cache management
getFontCacheStats = getFontCacheStats,
setFontCacheSize = setFontCacheSize,
clearFontCache = clearFontCache,
preloadFont = preloadFont,
resetFontCacheStats = resetFontCacheStats,
-- Numeric validation -- Numeric validation
isNaN = isNaN, isNaN = isNaN,
isInfinity = isInfinity, isInfinity = isInfinity,

View File

@@ -517,6 +517,103 @@ function TestEventHandler:test_processTouchEvents_no_onEvent()
handler:processTouchEvents() handler:processTouchEvents()
end end
-- Test: onEventDeferred flag defers callback execution
function TestEventHandler:test_onEventDeferred()
-- Mock FlexLove module
local deferredCallbacks = {}
local MockFlexLove = {
deferCallback = function(callback)
table.insert(deferredCallbacks, callback)
end
}
package.loaded["FlexLove"] = MockFlexLove
local eventsReceived = {}
local handler = createEventHandler({
onEventDeferred = true,
onEvent = function(el, event)
table.insert(eventsReceived, event)
end
})
local element = createMockElement()
handler:initialize(element)
local originalIsDown = love.mouse.isDown
love.mouse.isDown = function(button)
return button == 1
end
-- Press and release mouse button
handler:processMouseEvents(50, 50, true, true)
love.mouse.isDown = function() return false end
handler:processMouseEvents(50, 50, true, true)
-- Events should not be immediately executed
luaunit.assertEquals(#eventsReceived, 0)
-- Should have deferred callbacks queued
luaunit.assertTrue(#deferredCallbacks > 0)
-- Execute deferred callbacks
for _, callback in ipairs(deferredCallbacks) do
callback()
end
-- Now events should be received
luaunit.assertTrue(#eventsReceived > 0)
-- Check that we got a click event
local hasClick = false
for _, event in ipairs(eventsReceived) do
if event.type == "click" then
hasClick = true
break
end
end
luaunit.assertTrue(hasClick, "Should have received click event")
love.mouse.isDown = originalIsDown
package.loaded["FlexLove"] = nil
end
-- Test: onEventDeferred = false executes immediately
function TestEventHandler:test_onEventDeferred_false()
local eventsReceived = {}
local handler = createEventHandler({
onEventDeferred = false,
onEvent = function(el, event)
table.insert(eventsReceived, event)
end
})
local element = createMockElement()
handler:initialize(element)
local originalIsDown = love.mouse.isDown
love.mouse.isDown = function(button)
return button == 1
end
-- Press and release mouse button
handler:processMouseEvents(50, 50, true, true)
love.mouse.isDown = function() return false end
handler:processMouseEvents(50, 50, true, true)
-- Events should be immediately executed
luaunit.assertTrue(#eventsReceived > 0)
-- Check that we got a click event
local hasClick = false
for _, event in ipairs(eventsReceived) do
if event.type == "click" then
hasClick = true
break
end
end
luaunit.assertTrue(hasClick, "Should have received click event")
love.mouse.isDown = originalIsDown
end
if not _G.RUNNING_ALL_TESTS then if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run()) os.exit(luaunit.LuaUnit.run())
end end

View File

@@ -639,6 +639,99 @@ function TestFlexLove:testGetElementAtPositionOutside()
luaunit.assertNil(found) luaunit.assertNil(found)
end end
-- Test: deferCallback() queues callback
function TestFlexLove:testDeferCallbackQueuesCallback()
FlexLove.setMode("retained")
local called = false
FlexLove.deferCallback(function()
called = true
end)
-- Callback should not be called immediately
luaunit.assertFalse(called)
-- Callback should be called after executeDeferredCallbacks
FlexLove.draw()
luaunit.assertFalse(called) -- Still not called
FlexLove.executeDeferredCallbacks()
luaunit.assertTrue(called) -- Now called
end
-- Test: deferCallback() with multiple callbacks
function TestFlexLove:testDeferCallbackMultiple()
FlexLove.setMode("retained")
local order = {}
FlexLove.deferCallback(function()
table.insert(order, 1)
end)
FlexLove.deferCallback(function()
table.insert(order, 2)
end)
FlexLove.deferCallback(function()
table.insert(order, 3)
end)
FlexLove.draw()
FlexLove.executeDeferredCallbacks()
luaunit.assertEquals(#order, 3)
luaunit.assertEquals(order[1], 1)
luaunit.assertEquals(order[2], 2)
luaunit.assertEquals(order[3], 3)
end
-- Test: deferCallback() with non-function argument
function TestFlexLove:testDeferCallbackInvalidArgument()
FlexLove.setMode("retained")
-- Should warn but not crash
FlexLove.deferCallback("not a function")
FlexLove.deferCallback(123)
FlexLove.deferCallback(nil)
FlexLove.draw()
luaunit.assertTrue(true)
end
-- Test: deferCallback() clears queue after execution
function TestFlexLove:testDeferCallbackClearsQueue()
FlexLove.setMode("retained")
local callCount = 0
FlexLove.deferCallback(function()
callCount = callCount + 1
end)
FlexLove.draw()
FlexLove.executeDeferredCallbacks() -- First execution
luaunit.assertEquals(callCount, 1)
FlexLove.draw()
FlexLove.executeDeferredCallbacks() -- Second execution should not call again
luaunit.assertEquals(callCount, 1)
end
-- Test: deferCallback() handles callback errors gracefully
function TestFlexLove:testDeferCallbackWithError()
FlexLove.setMode("retained")
local called = false
FlexLove.deferCallback(function()
error("Intentional error")
end)
FlexLove.deferCallback(function()
called = true
end)
-- Should not crash, second callback should still execute
FlexLove.draw()
FlexLove.executeDeferredCallbacks()
luaunit.assertTrue(called)
end
-- Test: External modules are exposed -- Test: External modules are exposed
function TestFlexLove:testExternalModulesExposed() function TestFlexLove:testExternalModulesExposed()
luaunit.assertNotNil(FlexLove.Animation) luaunit.assertNotNil(FlexLove.Animation)

View File

@@ -0,0 +1,199 @@
-- Test font cache optimizations
package.path = package.path .. ";./?.lua;./modules/?.lua"
local luaunit = require("testing.luaunit")
local loveStub = require("testing.loveStub")
-- Set up stub before requiring modules
_G.love = loveStub
local utils = require("modules.utils")
TestFontCache = {}
function TestFontCache:setUp()
utils.clearFontCache()
utils.resetFontCacheStats()
love.timer.setTime(0) -- Reset timer for consistent timestamps
end
function TestFontCache:tearDown()
utils.clearFontCache()
utils.resetFontCacheStats()
utils.setFontCacheSize(50) -- Reset to default
end
function TestFontCache:testCacheHitOnRepeatedAccess()
-- First access should be a miss
utils.FONT_CACHE.get(16, nil)
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.misses, 1)
luaunit.assertEquals(stats1.hits, 0)
-- Second access should be a hit
utils.FONT_CACHE.get(16, nil)
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.hits, 1)
luaunit.assertEquals(stats2.misses, 1)
-- Third access should also be a hit
utils.FONT_CACHE.get(16, nil)
local stats3 = utils.getFontCacheStats()
luaunit.assertEquals(stats3.hits, 2)
luaunit.assertEquals(stats3.misses, 1)
end
function TestFontCache:testCacheMissOnFirstAccess()
utils.clearFontCache()
utils.resetFontCacheStats()
utils.FONT_CACHE.get(24, nil)
local stats = utils.getFontCacheStats()
luaunit.assertEquals(stats.misses, 1)
luaunit.assertEquals(stats.hits, 0)
end
function TestFontCache:testLRUEviction()
utils.setFontCacheSize(3)
-- Load 3 fonts (fills cache) with time steps to ensure different timestamps
utils.FONT_CACHE.get(10, nil)
love.timer.step(0.001)
utils.FONT_CACHE.get(12, nil)
love.timer.step(0.001)
utils.FONT_CACHE.get(14, nil)
love.timer.step(0.001)
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.size, 3)
luaunit.assertEquals(stats1.evictions, 0)
-- Load 4th font (triggers eviction of font 10 - the oldest)
utils.FONT_CACHE.get(16, nil)
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.size, 3)
luaunit.assertEquals(stats2.evictions, 1)
-- Access first font again - it should have been evicted (miss)
local initialMisses = stats2.misses
utils.FONT_CACHE.get(10, nil)
local stats3 = utils.getFontCacheStats()
luaunit.assertEquals(stats3.misses, initialMisses + 1) -- Should be a miss
end
function TestFontCache:testCacheSizeLimitEnforced()
utils.setFontCacheSize(5)
-- Load 10 fonts
for i = 1, 10 do
utils.FONT_CACHE.get(10 + i, nil)
end
local stats = utils.getFontCacheStats()
luaunit.assertEquals(stats.size, 5)
luaunit.assertTrue(stats.evictions >= 5)
end
function TestFontCache:testFontRounding()
-- Sizes should be rounded: 14.5 and 14.7 should map to same cache entry (15)
utils.FONT_CACHE.get(14.5, nil)
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.misses, 1)
utils.FONT_CACHE.get(14.7, nil)
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.hits, 1) -- Should be a hit because both round to 15
luaunit.assertEquals(stats2.misses, 1)
end
function TestFontCache:testCacheClear()
utils.FONT_CACHE.get(16, nil)
utils.FONT_CACHE.get(18, nil)
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.size, 2)
utils.clearFontCache()
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.size, 0)
end
function TestFontCache:testCacheKeyWithPath()
-- Different cache keys for same size, different paths
utils.FONT_CACHE.get(16, nil)
utils.FONT_CACHE.get(16, "fonts/custom.ttf")
local stats = utils.getFontCacheStats()
luaunit.assertEquals(stats.misses, 2) -- Both should be misses
luaunit.assertEquals(stats.size, 2)
end
function TestFontCache:testPreloadFont()
utils.clearFontCache()
utils.resetFontCacheStats()
-- Preload multiple sizes
utils.preloadFont(nil, {12, 14, 16, 18})
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.size, 4)
luaunit.assertEquals(stats1.misses, 4) -- All preloads are misses
-- Now access one - should be a hit
utils.FONT_CACHE.get(16, nil)
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.hits, 1)
end
function TestFontCache:testCacheHitRate()
utils.clearFontCache()
utils.resetFontCacheStats()
-- 1 miss, 9 hits = 90% hit rate
utils.FONT_CACHE.get(16, nil)
for i = 1, 9 do
utils.FONT_CACHE.get(16, nil)
end
local stats = utils.getFontCacheStats()
luaunit.assertEquals(stats.hitRate, 0.9)
end
function TestFontCache:testSetCacheSizeEvictsExcess()
utils.setFontCacheSize(10)
-- Load 10 fonts
for i = 1, 10 do
utils.FONT_CACHE.get(10 + i, nil)
end
local stats1 = utils.getFontCacheStats()
luaunit.assertEquals(stats1.size, 10)
-- Reduce cache size - should trigger evictions
utils.setFontCacheSize(5)
local stats2 = utils.getFontCacheStats()
luaunit.assertEquals(stats2.size, 5)
luaunit.assertTrue(stats2.evictions >= 5)
end
function TestFontCache:testMinimalCacheSize()
-- Minimum cache size is 1
utils.setFontCacheSize(0)
utils.FONT_CACHE.get(16, nil)
local stats = utils.getFontCacheStats()
luaunit.assertEquals(stats.size, 1)
end
-- Run tests if executed directly
if arg and arg[0]:find("font_cache_test%.lua$") then
os.exit(luaunit.LuaUnit.run())
end
return TestFontCache

View File

@@ -0,0 +1,167 @@
-- Test Performance Instrumentation
package.path = package.path .. ";./?.lua;./modules/?.lua"
local luaunit = require("testing.luaunit")
local loveStub = require("testing.loveStub")
-- Set up stub before requiring modules
_G.love = loveStub
local Performance = require("modules.Performance")
TestPerformanceInstrumentation = {}
function TestPerformanceInstrumentation:setUp()
Performance.reset()
Performance.enable()
end
function TestPerformanceInstrumentation:tearDown()
Performance.disable()
Performance.reset()
end
function TestPerformanceInstrumentation:testTimerStartStop()
Performance.startTimer("test_operation")
-- Simulate some work
local sum = 0
for i = 1, 1000 do
sum = sum + i
end
local elapsed = Performance.stopTimer("test_operation")
luaunit.assertNotNil(elapsed)
luaunit.assertTrue(elapsed >= 0)
local metrics = Performance.getMetrics()
luaunit.assertNotNil(metrics.timings["test_operation"])
luaunit.assertEquals(metrics.timings["test_operation"].count, 1)
end
function TestPerformanceInstrumentation:testMultipleTimers()
-- Start multiple timers
Performance.startTimer("layout")
Performance.startTimer("render")
local sum = 0
for i = 1, 100 do sum = sum + i end
Performance.stopTimer("layout")
Performance.stopTimer("render")
local metrics = Performance.getMetrics()
luaunit.assertNotNil(metrics.timings["layout"])
luaunit.assertNotNil(metrics.timings["render"])
end
function TestPerformanceInstrumentation:testFrameTiming()
Performance.startFrame()
-- Simulate frame work
local sum = 0
for i = 1, 1000 do
sum = sum + i
end
Performance.endFrame()
local frameMetrics = Performance.getFrameMetrics()
luaunit.assertNotNil(frameMetrics)
luaunit.assertEquals(frameMetrics.frameCount, 1)
luaunit.assertTrue(frameMetrics.lastFrameTime >= 0)
end
function TestPerformanceInstrumentation:testDrawCallCounting()
Performance.incrementCounter("draw_calls", 1)
Performance.incrementCounter("draw_calls", 1)
Performance.incrementCounter("draw_calls", 1)
local counter = Performance.getFrameCounter("draw_calls")
luaunit.assertEquals(counter, 3)
-- Reset and check
Performance.resetFrameCounters()
counter = Performance.getFrameCounter("draw_calls")
luaunit.assertEquals(counter, 0)
end
function TestPerformanceInstrumentation:testHUDToggle()
luaunit.assertFalse(Performance.getConfig().hudEnabled)
Performance.toggleHUD()
luaunit.assertTrue(Performance.getConfig().hudEnabled)
Performance.toggleHUD()
luaunit.assertFalse(Performance.getConfig().hudEnabled)
end
function TestPerformanceInstrumentation:testEnableDisable()
Performance.enable()
luaunit.assertTrue(Performance.isEnabled())
Performance.disable()
luaunit.assertFalse(Performance.isEnabled())
-- Timers should not record when disabled
Performance.startTimer("disabled_test")
local elapsed = Performance.stopTimer("disabled_test")
luaunit.assertNil(elapsed)
end
function TestPerformanceInstrumentation:testMeasureFunction()
local function expensiveOperation(n)
local sum = 0
for i = 1, n do
sum = sum + i
end
return sum
end
local wrapped = Performance.measure("expensive_op", expensiveOperation)
local result = wrapped(1000)
luaunit.assertEquals(result, 500500) -- sum of 1 to 1000
local metrics = Performance.getMetrics()
luaunit.assertNotNil(metrics.timings["expensive_op"])
luaunit.assertEquals(metrics.timings["expensive_op"].count, 1)
end
function TestPerformanceInstrumentation:testMemoryTracking()
Performance.updateMemory()
local memMetrics = Performance.getMemoryMetrics()
luaunit.assertNotNil(memMetrics)
luaunit.assertTrue(memMetrics.currentKb > 0)
luaunit.assertTrue(memMetrics.currentMb > 0)
luaunit.assertTrue(memMetrics.peakKb >= memMetrics.currentKb)
end
function TestPerformanceInstrumentation:testExportJSON()
Performance.startTimer("test_op")
Performance.stopTimer("test_op")
local json = Performance.exportJSON()
luaunit.assertNotNil(json)
luaunit.assertTrue(string.find(json, "fps") ~= nil)
luaunit.assertTrue(string.find(json, "test_op") ~= nil)
end
function TestPerformanceInstrumentation:testExportCSV()
Performance.startTimer("test_op")
Performance.stopTimer("test_op")
local csv = Performance.exportCSV()
luaunit.assertNotNil(csv)
luaunit.assertTrue(string.find(csv, "Name,Average") ~= nil)
luaunit.assertTrue(string.find(csv, "test_op") ~= nil)
end
-- Run tests if executed directly
if arg and arg[0]:find("performance_instrumentation_test%.lua$") then
os.exit(luaunit.LuaUnit.run())
end
return TestPerformanceInstrumentation

View File

@@ -0,0 +1,156 @@
local luaunit = require("testing.luaunit")
require("testing.loveStub")
local FlexLove = require("FlexLove")
local Performance = require("modules.Performance")
local Element = FlexLove.Element
TestPerformanceWarnings = {}
function TestPerformanceWarnings:setUp()
-- Enable performance warnings
Performance.setConfig("warningsEnabled", true)
Performance.resetShownWarnings()
end
function TestPerformanceWarnings:tearDown()
-- Reset warnings
Performance.resetShownWarnings()
end
-- Test hierarchy depth warning
function TestPerformanceWarnings:testHierarchyDepthWarning()
-- Create a deep hierarchy (20 levels)
local root = Element.new({
id = "root",
width = 100,
height = 100,
}, Element.defaultDependencies)
local current = root
for i = 1, 20 do
local child = Element.new({
id = "child_" .. i,
width = 50,
height = 50,
parent = current,
}, Element.defaultDependencies)
table.insert(current.children, child)
current = child
end
-- This should trigger a hierarchy depth warning
root:layoutChildren()
-- Check that element was created successfully despite warning
luaunit.assertNotNil(current)
luaunit.assertEquals(current:getHierarchyDepth(), 20)
end
-- Test element count warning
function TestPerformanceWarnings:testElementCountWarning()
-- Create a container with many children (simulating 1000+ elements)
local root = Element.new({
id = "root",
width = 1000,
height = 1000,
}, Element.defaultDependencies)
-- Add many child elements
for i = 1, 50 do -- Keep test fast, just verify the counting logic works
local child = Element.new({
id = "child_" .. i,
width = 20,
height = 20,
parent = root,
}, Element.defaultDependencies)
table.insert(root.children, child)
end
local count = root:countElements()
luaunit.assertEquals(count, 51) -- root + 50 children
end
-- Test animation count warning
function TestPerformanceWarnings:testAnimationTracking()
local root = Element.new({
id = "root",
width = 100,
height = 100,
}, Element.defaultDependencies)
-- Add some animated children
for i = 1, 3 do
local child = Element.new({
id = "animated_child_" .. i,
width = 20,
height = 20,
parent = root,
}, Element.defaultDependencies)
-- Add mock animation
child.animation = {
update = function()
return false
end,
interpolate = function()
return { width = 20, height = 20 }
end,
}
table.insert(root.children, child)
end
local animCount = root:_countActiveAnimations()
luaunit.assertEquals(animCount, 3)
end
-- Test warnings can be disabled
function TestPerformanceWarnings:testWarningsCanBeDisabled()
Performance.setConfig("warningsEnabled", false)
-- Create deep hierarchy
local root = Element.new({
id = "root",
width = 100,
height = 100,
}, Element.defaultDependencies)
local current = root
for i = 1, 20 do
local child = Element.new({
id = "child_" .. i,
width = 50,
height = 50,
parent = current,
}, Element.defaultDependencies)
table.insert(current.children, child)
current = child
end
-- Should not trigger warning (but should still create elements)
root:layoutChildren()
luaunit.assertEquals(current:getHierarchyDepth(), 20)
-- Re-enable for other tests
Performance.setConfig("warningsEnabled", true)
end
-- Test layout recalculation tracking
function TestPerformanceWarnings:testLayoutRecalculationTracking()
local root = Element.new({
id = "root",
width = 100,
height = 100,
}, Element.defaultDependencies)
-- Layout multiple times (simulating layout thrashing)
for i = 1, 5 do
root:layoutChildren()
end
-- Should complete without crashing
luaunit.assertNotNil(root)
end
return TestPerformanceWarnings