Performance and reporting improvements
This commit is contained in:
@@ -4,6 +4,14 @@ local Blur = {}
|
||||
local canvasCache = {}
|
||||
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
|
||||
---@param taps number -- Number of samples (must be odd, >= 3)
|
||||
---@param offset number
|
||||
@@ -105,6 +113,53 @@ local function releaseCanvas(canvas)
|
||||
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
|
||||
---@param quality number -- Quality level (1-10, higher = better quality but slower)
|
||||
---@return table -- Blur effect instance
|
||||
@@ -232,7 +287,7 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
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.setShader(blurInstance.shader)
|
||||
@@ -259,11 +314,13 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
|
||||
|
||||
releaseCanvas(canvas1)
|
||||
releaseCanvas(canvas2)
|
||||
releaseQuad(quad)
|
||||
end
|
||||
|
||||
--- Clear canvas cache (call on window resize)
|
||||
function Blur.clearCache()
|
||||
canvasCache = {}
|
||||
quadCache = {}
|
||||
end
|
||||
|
||||
return Blur
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
---@field transform TransformProps -- Transform properties for animations and styling
|
||||
---@field transition TransitionProps -- Transition settings for animations
|
||||
---@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 onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
|
||||
---@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 onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
|
||||
---@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 onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
||||
---@field units table -- Original unit specifications for responsive behavior
|
||||
---@field _eventHandler EventHandler -- Event handler instance for input processing
|
||||
---@field _explicitlyAbsolute boolean?
|
||||
@@ -215,7 +221,10 @@ function Element.new(props, deps)
|
||||
self._stateId = self.id
|
||||
|
||||
-- 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
|
||||
local state = self._deps.StateManager.getState(self._stateId)
|
||||
if state then
|
||||
@@ -1769,6 +1778,11 @@ function Element:applyPositioningOffsets(element)
|
||||
end
|
||||
|
||||
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
|
||||
self._layoutEngine:layoutChildren()
|
||||
end
|
||||
@@ -1976,6 +1990,11 @@ end
|
||||
--- Update element (propagate to children)
|
||||
---@param dt number
|
||||
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
|
||||
if self._stateId and self._deps.Context._immediateMode then
|
||||
local state = self._deps.StateManager.getState(self._stateId)
|
||||
@@ -2632,4 +2651,94 @@ function Element:keypressed(key, scancode, isrepeat)
|
||||
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
|
||||
|
||||
@@ -279,6 +279,36 @@ ErrorCodes.codes = {
|
||||
description = "Module initialization failed",
|
||||
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
|
||||
|
||||
@@ -499,11 +499,8 @@ function ErrorHandler.warn(module, codeOrMessage, messageOrDetails, detailsOrSug
|
||||
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)
|
||||
|
||||
local formattedMessage = formatMessage(module, "Warning", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion)
|
||||
print(formattedMessage)
|
||||
end
|
||||
|
||||
--- Validate that a value is not nil
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---@class EventHandler
|
||||
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||
---@field onEventDeferred boolean?
|
||||
---@field _pressed table<number, boolean>
|
||||
---@field _lastClickTime number?
|
||||
---@field _lastClickButton number?
|
||||
@@ -32,6 +33,7 @@ function EventHandler.new(config, deps)
|
||||
self._utils = deps.utils
|
||||
|
||||
self.onEvent = config.onEvent
|
||||
self.onEventDeferred = config.onEventDeferred
|
||||
|
||||
self._pressed = config._pressed or {}
|
||||
|
||||
@@ -101,7 +103,16 @@ end
|
||||
---@param isHovering boolean Whether mouse is over element
|
||||
---@param isActiveElement boolean Whether this is the top element at mouse position
|
||||
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 Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("event_mouse")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -140,6 +151,9 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
|
||||
end
|
||||
end
|
||||
end
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("event_mouse")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -190,6 +204,11 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("event_mouse")
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle mouse button press
|
||||
@@ -214,18 +233,16 @@ function EventHandler:_handleMousePress(mx, my, button)
|
||||
end
|
||||
|
||||
-- Fire press event
|
||||
if self.onEvent then
|
||||
local modifiers = self._utils.getModifiers()
|
||||
local pressEvent = self._InputEvent.new({
|
||||
type = "press",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self.onEvent(element, pressEvent)
|
||||
end
|
||||
local modifiers = self._utils.getModifiers()
|
||||
local pressEvent = self._InputEvent.new({
|
||||
type = "press",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self:_invokeCallback(element, pressEvent)
|
||||
|
||||
self._pressed[button] = true
|
||||
|
||||
@@ -259,7 +276,7 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
|
||||
|
||||
if lastX ~= mx or lastY ~= my then
|
||||
-- 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 dx = mx - self._dragStartX[button]
|
||||
local dy = my - self._dragStartY[button]
|
||||
@@ -274,7 +291,7 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self.onEvent(element, dragEvent)
|
||||
self:_invokeCallback(element, dragEvent)
|
||||
end
|
||||
|
||||
-- Handle text selection drag for editable elements
|
||||
@@ -325,17 +342,15 @@ function EventHandler:_handleMouseRelease(mx, my, button)
|
||||
end
|
||||
|
||||
-- Fire click event
|
||||
if self.onEvent then
|
||||
local clickEvent = self._InputEvent.new({
|
||||
type = eventType,
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self.onEvent(element, clickEvent)
|
||||
end
|
||||
local clickEvent = self._InputEvent.new({
|
||||
type = eventType,
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, clickEvent)
|
||||
|
||||
self._pressed[button] = false
|
||||
|
||||
@@ -367,22 +382,20 @@ function EventHandler:_handleMouseRelease(mx, my, button)
|
||||
end
|
||||
|
||||
-- Fire release event
|
||||
if self.onEvent then
|
||||
local releaseEvent = self._InputEvent.new({
|
||||
type = "release",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self.onEvent(element, releaseEvent)
|
||||
end
|
||||
local releaseEvent = self._InputEvent.new({
|
||||
type = "release",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, releaseEvent)
|
||||
end
|
||||
|
||||
--- Process touch events in the update cycle
|
||||
function EventHandler:processTouchEvents()
|
||||
if not self._element or not self.onEvent then
|
||||
if not self._element then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -408,7 +421,7 @@ function EventHandler:processTouchEvents()
|
||||
modifiers = self._utils.getModifiers(),
|
||||
clickCount = 1,
|
||||
})
|
||||
self.onEvent(element, touchEvent)
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self._touchPressed[id] = false
|
||||
end
|
||||
end
|
||||
@@ -437,4 +450,29 @@ function EventHandler:isButtonPressed(button)
|
||||
return self._pressed[button] == true
|
||||
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
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
---@field _AlignItems table
|
||||
---@field _AlignSelf table
|
||||
---@field _FlexWrap table
|
||||
---@field _layoutCount number Track layout recalculations per frame
|
||||
---@field _lastFrameCount number Last frame number for resetting counters
|
||||
local LayoutEngine = {}
|
||||
LayoutEngine.__index = LayoutEngine
|
||||
|
||||
@@ -84,6 +86,10 @@ function LayoutEngine.new(props, deps)
|
||||
-- Element reference (will be set via initialize)
|
||||
self.element = nil
|
||||
|
||||
-- Performance tracking
|
||||
self._layoutCount = 0
|
||||
self._lastFrameCount = 0
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -146,6 +152,16 @@ function LayoutEngine:layoutChildren()
|
||||
return
|
||||
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
|
||||
-- Absolute/Relative positioned containers don't layout their children according to flex rules,
|
||||
-- but they should still apply CSS positioning offsets to their children
|
||||
@@ -159,18 +175,32 @@ function LayoutEngine:layoutChildren()
|
||||
if self.element._detectOverflow then
|
||||
self.element:_detectOverflow()
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle grid layout
|
||||
if self.positioning == self._Positioning.GRID then
|
||||
self._Grid.layoutGridItems(self.element)
|
||||
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local childCount = #self.element.children
|
||||
|
||||
if childCount == 0 then
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -569,6 +599,11 @@ function LayoutEngine:layoutChildren()
|
||||
if self.element._detectOverflow then
|
||||
self.element:_detectOverflow()
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() then
|
||||
Performance.stopTimer("layout_" .. (self.element.id or "unnamed"))
|
||||
end
|
||||
end
|
||||
|
||||
--- Calculate auto width based on children
|
||||
@@ -940,4 +975,37 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
|
||||
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
|
||||
|
||||
@@ -3,20 +3,52 @@
|
||||
---@class 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
|
||||
local config = {
|
||||
enabled = false,
|
||||
hudEnabled = false,
|
||||
hudToggleKey = "f3",
|
||||
hudPosition = { x = 10, y = 10 },
|
||||
warningThresholdMs = 13.0, -- Yellow warning
|
||||
criticalThresholdMs = 16.67, -- Red warning (60 FPS)
|
||||
logToConsole = false,
|
||||
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
|
||||
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 = {
|
||||
frameCount = 0,
|
||||
totalTime = 0,
|
||||
@@ -35,6 +67,17 @@ local memoryMetrics = {
|
||||
}
|
||||
local warnings = {}
|
||||
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
|
||||
--- @param options table? Optional configuration overrides
|
||||
@@ -73,6 +116,7 @@ function Performance.reset()
|
||||
timers = {}
|
||||
metrics = {}
|
||||
warnings = {}
|
||||
shownWarnings = {}
|
||||
frameMetrics.frameCount = 0
|
||||
frameMetrics.totalTime = 0
|
||||
frameMetrics.lastFrameTime = 0
|
||||
@@ -119,6 +163,7 @@ function Performance.stopTimer(name)
|
||||
min = math.huge,
|
||||
max = 0,
|
||||
average = 0,
|
||||
lastUsed = love.timer.getTime(),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -128,6 +173,7 @@ function Performance.stopTimer(name)
|
||||
m.min = math.min(m.min, elapsed)
|
||||
m.max = math.max(m.max, elapsed)
|
||||
m.average = m.total / m.count
|
||||
m.lastUsed = love.timer.getTime()
|
||||
|
||||
-- Check for warnings
|
||||
if elapsed > config.criticalThresholdMs then
|
||||
@@ -160,6 +206,24 @@ function Performance.measure(name, fn)
|
||||
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)
|
||||
function Performance.startFrame()
|
||||
if not config.enabled then
|
||||
@@ -184,16 +248,54 @@ function Performance.endFrame()
|
||||
frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime)
|
||||
frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime)
|
||||
|
||||
-- Update FPS
|
||||
if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then
|
||||
frameMetrics.fps = math.floor(1000 / frameTime + 0.5)
|
||||
frameMetrics.lastFpsUpdate = now
|
||||
end
|
||||
-- Note: FPS is now calculated from actual delta time in updateDeltaTime()
|
||||
-- frameTime here represents processing time, not actual frame rate
|
||||
|
||||
-- Check for frame drops
|
||||
if frameTime > config.criticalThresholdMs then
|
||||
Performance.addWarning("frame", frameTime, "critical")
|
||||
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
|
||||
|
||||
--- Update memory metrics
|
||||
@@ -223,17 +325,54 @@ function Performance.addWarning(name, value, level)
|
||||
return
|
||||
end
|
||||
|
||||
table.insert(warnings, {
|
||||
local warning = {
|
||||
name = name,
|
||||
value = value,
|
||||
level = level,
|
||||
time = love.timer.getTime(),
|
||||
})
|
||||
}
|
||||
|
||||
table.insert(warnings, warning)
|
||||
|
||||
-- Keep only last 100 warnings
|
||||
if #warnings > 100 then
|
||||
table.remove(warnings, 1)
|
||||
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
|
||||
|
||||
--- Get current FPS
|
||||
@@ -346,15 +485,16 @@ function Performance.renderHUD(x, y)
|
||||
return
|
||||
end
|
||||
|
||||
x = x or 10
|
||||
y = y or 10
|
||||
-- Use config position if x/y not provided
|
||||
x = x or config.hudPosition.x
|
||||
y = y or config.hudPosition.y
|
||||
|
||||
local fm = Performance.getFrameMetrics()
|
||||
local mm = Performance.getMemoryMetrics()
|
||||
|
||||
-- Background
|
||||
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
|
||||
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)
|
||||
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
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
currentY = currentY + 5
|
||||
|
||||
-- Top timings
|
||||
@@ -432,4 +583,259 @@ function Performance.setConfig(key, value)
|
||||
config[key] = value
|
||||
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
|
||||
|
||||
@@ -305,8 +305,20 @@ end
|
||||
--- Main draw method - renders all visual layers
|
||||
---@param backdropCanvas table|nil Backdrop canvas for backdrop blur
|
||||
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)
|
||||
if self.opacity <= 0 then
|
||||
if Performance and Performance.isEnabled() and elementId then
|
||||
Performance.stopTimer("render_" .. elementId)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -354,6 +366,11 @@ function Renderer:draw(backdropCanvas)
|
||||
|
||||
-- LAYER 3: Draw borders on top of theme
|
||||
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
|
||||
|
||||
--- Get font for element (resolves from theme or fontFamily)
|
||||
|
||||
@@ -470,7 +470,7 @@ function StateManager.configure(newConfig)
|
||||
end
|
||||
|
||||
--- 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()
|
||||
local stateCount = StateManager.getStateCount()
|
||||
local oldest = nil
|
||||
@@ -485,11 +485,32 @@ function StateManager.getStats()
|
||||
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 {
|
||||
stateCount = stateCount,
|
||||
frameNumber = frameNumber,
|
||||
oldestState = oldest,
|
||||
newestState = newest,
|
||||
callSiteCounterCount = callSiteCount,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -508,6 +529,16 @@ function StateManager.dumpStates()
|
||||
return dump
|
||||
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)
|
||||
function StateManager.reset()
|
||||
stateStore = {}
|
||||
|
||||
@@ -139,39 +139,94 @@ local function resolveImagePath(path)
|
||||
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
|
||||
end
|
||||
|
||||
-- Font cache with LRU eviction
|
||||
local FONT_CACHE = {}
|
||||
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
|
||||
---@param size number
|
||||
---@param fontPath string?
|
||||
---@return love.Font
|
||||
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
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, font = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
FONT_CACHE[cacheKey] = font
|
||||
else
|
||||
print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font")
|
||||
FONT_CACHE[cacheKey] = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
FONT_CACHE[cacheKey] = love.graphics.newFont(size)
|
||||
end
|
||||
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||
|
||||
table.insert(FONT_CACHE_ORDER, cacheKey)
|
||||
|
||||
if #FONT_CACHE_ORDER > FONT_CACHE_MAX_SIZE then
|
||||
local oldestKey = table.remove(FONT_CACHE_ORDER, 1)
|
||||
FONT_CACHE[oldestKey] = nil
|
||||
end
|
||||
if FONT_CACHE[cacheKey] then
|
||||
-- Cache hit
|
||||
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
|
||||
updateCacheAccess(cacheKey)
|
||||
return FONT_CACHE[cacheKey].font
|
||||
end
|
||||
return FONT_CACHE[cacheKey]
|
||||
|
||||
-- Cache miss
|
||||
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||
|
||||
local font
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
font = result
|
||||
else
|
||||
print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font")
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
-- 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
|
||||
|
||||
-- Evict if cache is full
|
||||
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||
evictLRU()
|
||||
end
|
||||
|
||||
return font
|
||||
end
|
||||
|
||||
--- Get font for text size (cached)
|
||||
@@ -948,6 +1003,92 @@ local function validateDimension(value)
|
||||
})
|
||||
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 {
|
||||
enums = enums,
|
||||
FONT_CACHE = FONT_CACHE,
|
||||
@@ -981,6 +1122,12 @@ return {
|
||||
validatePath = validatePath,
|
||||
getFileExtension = getFileExtension,
|
||||
hasAllowedExtension = hasAllowedExtension,
|
||||
-- Font cache management
|
||||
getFontCacheStats = getFontCacheStats,
|
||||
setFontCacheSize = setFontCacheSize,
|
||||
clearFontCache = clearFontCache,
|
||||
preloadFont = preloadFont,
|
||||
resetFontCacheStats = resetFontCacheStats,
|
||||
-- Numeric validation
|
||||
isNaN = isNaN,
|
||||
isInfinity = isInfinity,
|
||||
|
||||
Reference in New Issue
Block a user