memory tooling, state handling changes

This commit is contained in:
Michael Freno
2025-11-25 09:50:57 -05:00
parent 9918df5ea8
commit d3014200da
25 changed files with 3528 additions and 1016 deletions

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ docs/doc.md
docs/node_modules docs/node_modules
releases/ releases/
*.log* *.log*
memory_scan*
*_report*

View File

@@ -1,7 +1,7 @@
# FlexLöve Agent Guidelines # FlexLöve Agent Guidelines
## Testing ## Testing
- **Run all tests**: `lua testing/runAll.lua` (coverage report in `luacov.report.out`) - **Run all tests**: `lua testing/runAll.lua --no-coverage`
- **Run single test**: `lua testing/__tests__/<test_file>.lua` - **Run single test**: `lua testing/__tests__/<test_file>.lua`
- **Test immediate mode**: Call `FlexLove.setMode("immediate")` in `setUp()`, then `FlexLove.beginFrame()`/`FlexLove.endFrame()` to trigger layout - **Test immediate mode**: Call `FlexLove.setMode("immediate")` in `setUp()`, then `FlexLove.beginFrame()`/`FlexLove.endFrame()` to trigger layout

View File

@@ -325,6 +325,24 @@ function flexlove.beginFrame()
-- Start performance frame timing -- Start performance frame timing
flexlove._Performance:startFrame() flexlove._Performance:startFrame()
-- Cleanup elements from PREVIOUS frame (after they've been drawn)
-- This breaks circular references and allows GC to collect memory
-- Note: Cleanup is minimal to preserve functionality
if flexlove._currentFrameElements then
local function cleanupChildren(elem)
for _, child in ipairs(elem.children) do
cleanupChildren(child)
end
elem:_cleanup()
end
for _, element in ipairs(flexlove._currentFrameElements) do
if not element.parent then
cleanupChildren(element)
end
end
end
flexlove._frameNumber = flexlove._frameNumber + 1 flexlove._frameNumber = flexlove._frameNumber + 1
StateManager.incrementFrame() StateManager.incrementFrame()
flexlove._currentFrameElements = {} flexlove._currentFrameElements = {}

View File

@@ -288,21 +288,20 @@ function Element.new(props)
if Element._Context._immediateMode and self._stateId and self._stateId ~= "" then if Element._Context._immediateMode and self._stateId and self._stateId ~= "" then
local state = Element._StateManager.getState(self._stateId) local state = Element._StateManager.getState(self._stateId)
if state then if state then
-- Restore EventHandler state from StateManager -- Restore EventHandler state from StateManager (sparse storage - provide defaults)
eventHandlerConfig._pressed = state._pressed eventHandlerConfig._pressed = state._pressed or {}
eventHandlerConfig._lastClickTime = state._lastClickTime eventHandlerConfig._lastClickTime = state._lastClickTime
eventHandlerConfig._lastClickButton = state._lastClickButton eventHandlerConfig._lastClickButton = state._lastClickButton
eventHandlerConfig._clickCount = state._clickCount eventHandlerConfig._clickCount = state._clickCount or 0
eventHandlerConfig._dragStartX = state._dragStartX eventHandlerConfig._dragStartX = state._dragStartX or {}
eventHandlerConfig._dragStartY = state._dragStartY eventHandlerConfig._dragStartY = state._dragStartY or {}
eventHandlerConfig._lastMouseX = state._lastMouseX eventHandlerConfig._lastMouseX = state._lastMouseX or {}
eventHandlerConfig._lastMouseY = state._lastMouseY eventHandlerConfig._lastMouseY = state._lastMouseY or {}
eventHandlerConfig._hovered = state._hovered eventHandlerConfig._hovered = state._hovered
end end
end end
self._eventHandler = Element._EventHandler.new(eventHandlerConfig, eventHandlerDeps) self._eventHandler = Element._EventHandler.new(eventHandlerConfig, eventHandlerDeps)
self._eventHandler:initialize(self)
self._themeManager = Element._Theme.Manager.new({ self._themeManager = Element._Theme.Manager.new({
theme = props.theme or Element._Context.defaultTheme, theme = props.theme or Element._Context.defaultTheme,
@@ -313,7 +312,6 @@ function Element.new(props)
scaleCorners = props.scaleCorners, scaleCorners = props.scaleCorners,
scalingAlgorithm = props.scalingAlgorithm, scalingAlgorithm = props.scalingAlgorithm,
}) })
self._themeManager:initialize(self)
-- Expose theme properties for backward compatibility -- Expose theme properties for backward compatibility
self.theme = self._themeManager.theme self.theme = self._themeManager.theme
@@ -418,24 +416,27 @@ function Element.new(props)
------ add non-hereditary ------ ------ add non-hereditary ------
--- self drawing--- --- self drawing---
-- Handle border (can be number or table) -- OPTIMIZATION: Handle border - only create table if border exists
-- This saves ~80 bytes per element without borders
if type(props.border) == "table" then if type(props.border) == "table" then
-- Check if any border side is truthy
local hasAnyBorder = props.border.top or props.border.right or props.border.bottom or props.border.left
if hasAnyBorder then
self.border = { self.border = {
top = props.border.top or false, top = props.border.top or false,
right = props.border.right or false, right = props.border.right or false,
bottom = props.border.bottom or false, bottom = props.border.bottom or false,
left = props.border.left or false, left = props.border.left or false,
} }
else
self.border = nil
end
elseif props.border then elseif props.border then
-- If border is a number or truthy value, keep it as-is -- If border is a number or truthy value, keep it as-is
self.border = props.border self.border = props.border
else else
self.border = { -- No border specified - use nil instead of table with all false
top = false, self.border = nil
right = false,
bottom = false,
left = false,
}
end end
self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1) self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1)
self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0) self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0)
@@ -452,30 +453,34 @@ function Element.new(props)
-- Set transform property (optional) -- Set transform property (optional)
self.transform = props.transform or nil self.transform = props.transform or nil
-- Handle cornerRadius (can be number or table) -- OPTIMIZATION: Handle cornerRadius - store as number or table, nil if all zeros
-- This saves ~80 bytes per element without rounded corners
if props.cornerRadius then if props.cornerRadius then
if type(props.cornerRadius) == "number" then if type(props.cornerRadius) == "number" then
self.cornerRadius = { -- Store as number for uniform radius (compact)
topLeft = props.cornerRadius, if props.cornerRadius ~= 0 then
topRight = props.cornerRadius, self.cornerRadius = props.cornerRadius
bottomLeft = props.cornerRadius,
bottomRight = props.cornerRadius,
}
else else
self.cornerRadius = nil
end
else
-- Store as table only if non-zero values exist
local hasNonZero = props.cornerRadius.topLeft or props.cornerRadius.topRight or
props.cornerRadius.bottomLeft or props.cornerRadius.bottomRight
if hasNonZero then
self.cornerRadius = { self.cornerRadius = {
topLeft = props.cornerRadius.topLeft or 0, topLeft = props.cornerRadius.topLeft or 0,
topRight = props.cornerRadius.topRight or 0, topRight = props.cornerRadius.topRight or 0,
bottomLeft = props.cornerRadius.bottomLeft or 0, bottomLeft = props.cornerRadius.bottomLeft or 0,
bottomRight = props.cornerRadius.bottomRight or 0, bottomRight = props.cornerRadius.bottomRight or 0,
} }
else
self.cornerRadius = nil
end
end end
else else
self.cornerRadius = { -- No cornerRadius specified - use nil instead of table with all zeros
topLeft = 0, self.cornerRadius = nil
topRight = 0,
bottomLeft = 0,
bottomRight = 0,
}
end end
-- For editable elements, default text to empty string if not provided -- For editable elements, default text to empty string if not provided
@@ -622,7 +627,6 @@ function Element.new(props)
contentBlur = self.contentBlur, contentBlur = self.contentBlur,
backdropBlur = self.backdropBlur, backdropBlur = self.backdropBlur,
}, rendererDeps) }, rendererDeps)
self._renderer:initialize(self)
--- self positioning --- --- self positioning ---
local viewportWidth, viewportHeight = Element._Units.getViewport() local viewportWidth, viewportHeight = Element._Units.getViewport()
@@ -1417,7 +1421,6 @@ function Element.new(props)
_scrollX = props._scrollX, _scrollX = props._scrollX,
_scrollY = props._scrollY, _scrollY = props._scrollY,
}, scrollManagerDeps) }, scrollManagerDeps)
self._scrollManager:initialize(self)
-- Expose ScrollManager properties for backward compatibility (Renderer access) -- Expose ScrollManager properties for backward compatibility (Renderer access)
self.overflow = self._scrollManager.overflow self.overflow = self._scrollManager.overflow
@@ -1456,7 +1459,7 @@ function Element.new(props)
-- Initialize TextEditor after element is fully constructed -- Initialize TextEditor after element is fully constructed
if self._textEditor then if self._textEditor then
self._textEditor:initialize(self) self._textEditor:restoreState(self)
end end
return self return self
@@ -1519,7 +1522,7 @@ end
--- Detect if content overflows container bounds (delegates to ScrollManager) --- Detect if content overflows container bounds (delegates to ScrollManager)
function Element:_detectOverflow() function Element:_detectOverflow()
if self._scrollManager then if self._scrollManager then
self._scrollManager:detectOverflow() self._scrollManager:detectOverflow(self)
self:_syncScrollManagerState() self:_syncScrollManagerState()
end end
end end
@@ -1539,7 +1542,7 @@ end
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} ---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
function Element:_calculateScrollbarDimensions() function Element:_calculateScrollbarDimensions()
if self._scrollManager then if self._scrollManager then
return self._scrollManager:calculateScrollbarDimensions() return self._scrollManager:calculateScrollbarDimensions(self)
end end
-- Return empty result if no ScrollManager -- Return empty result if no ScrollManager
return { return {
@@ -1556,7 +1559,7 @@ end
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} ---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
function Element:_getScrollbarAtPosition(mouseX, mouseY) function Element:_getScrollbarAtPosition(mouseX, mouseY)
if self._scrollManager then if self._scrollManager then
return self._scrollManager:getScrollbarAtPosition(mouseX, mouseY) return self._scrollManager:getScrollbarAtPosition(self, mouseX, mouseY)
end end
return nil return nil
end end
@@ -1568,7 +1571,7 @@ end
---@return boolean -- True if event was consumed ---@return boolean -- True if event was consumed
function Element:_handleScrollbarPress(mouseX, mouseY, button) function Element:_handleScrollbarPress(mouseX, mouseY, button)
if self._scrollManager then if self._scrollManager then
local consumed = self._scrollManager:handleMousePress(mouseX, mouseY, button) local consumed = self._scrollManager:handleMousePress(self, mouseX, mouseY, button)
self:_syncScrollManagerState() self:_syncScrollManagerState()
return consumed return consumed
end end
@@ -1581,7 +1584,7 @@ end
---@return boolean -- True if event was consumed ---@return boolean -- True if event was consumed
function Element:_handleScrollbarDrag(mouseX, mouseY) function Element:_handleScrollbarDrag(mouseX, mouseY)
if self._scrollManager then if self._scrollManager then
local consumed = self._scrollManager:handleMouseMove(mouseX, mouseY) local consumed = self._scrollManager:handleMouseMove(self, mouseX, mouseY)
self:_syncScrollManagerState() self:_syncScrollManagerState()
return consumed return consumed
end end
@@ -1999,7 +2002,7 @@ function Element:draw(backdropCanvas)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
-- LAYERS 0.5-3: Delegate visual rendering (backdrop blur, background, image, theme, borders) to Renderer module -- LAYERS 0.5-3: Delegate visual rendering (backdrop blur, background, image, theme, borders) to Renderer module
self._renderer:draw(backdropCanvas) self._renderer:draw(self, backdropCanvas)
-- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module -- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module
self._renderer:drawText(self) self._renderer:drawText(self)
@@ -2033,10 +2036,17 @@ function Element:draw(backdropCanvas)
end) end)
-- Check if we need to clip children to rounded corners -- Check if we need to clip children to rounded corners
local hasRoundedCorners = self.cornerRadius.topLeft > 0 local hasRoundedCorners = false
if self.cornerRadius then
if type(self.cornerRadius) == "number" then
hasRoundedCorners = self.cornerRadius > 0
else
hasRoundedCorners = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0 or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0 or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0 or self.cornerRadius.bottomRight > 0
end
end
-- Helper function to draw children (with or without clipping) -- Helper function to draw children (with or without clipping)
local function drawChildren() local function drawChildren()
@@ -2172,7 +2182,7 @@ function Element:update(dt)
-- Update text editor cursor blink -- Update text editor cursor blink
if self._textEditor then if self._textEditor then
self._textEditor:update(dt) self._textEditor:update(self, dt)
end end
-- Update animation if exists -- Update animation if exists
@@ -2266,7 +2276,7 @@ function Element:update(dt)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
if self._scrollManager then if self._scrollManager then
self._scrollManager:updateHoverState(mx, my) self._scrollManager:updateHoverState(self, mx, my)
self:_syncScrollManagerState() self:_syncScrollManagerState()
end end
@@ -2366,7 +2376,7 @@ function Element:update(dt)
-- Process mouse events through EventHandler FIRST -- Process mouse events through EventHandler FIRST
-- This ensures pressed states are updated before theme state is calculated -- This ensures pressed states are updated before theme state is calculated
self._eventHandler:processMouseEvents(mx, my, isHovering, isActiveElement) self._eventHandler:processMouseEvents(self, mx, my, isHovering, isActiveElement)
-- In immediate mode, save EventHandler state to StateManager after processing events -- In immediate mode, save EventHandler state to StateManager after processing events
if self._stateId and Element._Context._immediateMode and self._stateId ~= "" then if self._stateId and Element._Context._immediateMode and self._stateId ~= "" then
@@ -2417,7 +2427,7 @@ function Element:update(dt)
end end
-- Process touch events through EventHandler -- Process touch events through EventHandler
self._eventHandler:processTouchEvents() self._eventHandler:processTouchEvents(self)
end end
end end
@@ -2589,7 +2599,7 @@ end
---@param position number -- Character index (0-based) ---@param position number -- Character index (0-based)
function Element:setCursorPosition(position) function Element:setCursorPosition(position)
if self._textEditor then if self._textEditor then
self._textEditor:setCursorPosition(position) self._textEditor:setCursorPosition(self, position)
end end
end end
@@ -2606,49 +2616,49 @@ end
---@param delta number -- Number of characters to move (positive or negative) ---@param delta number -- Number of characters to move (positive or negative)
function Element:moveCursorBy(delta) function Element:moveCursorBy(delta)
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorBy(delta) self._textEditor:moveCursorBy(self, delta)
end end
end end
--- Move cursor to start of text --- Move cursor to start of text
function Element:moveCursorToStart() function Element:moveCursorToStart()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToStart() self._textEditor:moveCursorToStart(self)
end end
end end
--- Move cursor to end of text --- Move cursor to end of text
function Element:moveCursorToEnd() function Element:moveCursorToEnd()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToEnd() self._textEditor:moveCursorToEnd(self)
end end
end end
--- Move cursor to start of current line --- Move cursor to start of current line
function Element:moveCursorToLineStart() function Element:moveCursorToLineStart()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToLineStart() self._textEditor:moveCursorToLineStart(self)
end end
end end
--- Move cursor to end of current line --- Move cursor to end of current line
function Element:moveCursorToLineEnd() function Element:moveCursorToLineEnd()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToLineEnd() self._textEditor:moveCursorToLineEnd(self)
end end
end end
--- Move cursor to start of previous word --- Move cursor to start of previous word
function Element:moveCursorToPreviousWord() function Element:moveCursorToPreviousWord()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToPreviousWord() self._textEditor:moveCursorToPreviousWord(self)
end end
end end
--- Move cursor to start of next word --- Move cursor to start of next word
function Element:moveCursorToNextWord() function Element:moveCursorToNextWord()
if self._textEditor then if self._textEditor then
self._textEditor:moveCursorToNextWord() self._textEditor:moveCursorToNextWord(self)
end end
end end
@@ -2661,7 +2671,7 @@ end
---@param endPos number -- End position (inclusive) ---@param endPos number -- End position (inclusive)
function Element:setSelection(startPos, endPos) function Element:setSelection(startPos, endPos)
if self._textEditor then if self._textEditor then
self._textEditor:setSelection(startPos, endPos) self._textEditor:setSelection(self, startPos, endPos)
end end
end end
@@ -2686,14 +2696,14 @@ end
--- Clear selection --- Clear selection
function Element:clearSelection() function Element:clearSelection()
if self._textEditor then if self._textEditor then
self._textEditor:clearSelection() self._textEditor:clearSelection(self)
end end
end end
--- Select all text --- Select all text
function Element:selectAll() function Element:selectAll()
if self._textEditor then if self._textEditor then
self._textEditor:selectAll() self._textEditor:selectAll(self)
end end
end end
@@ -2710,10 +2720,10 @@ end
---@return boolean -- True if text was deleted ---@return boolean -- True if text was deleted
function Element:deleteSelection() function Element:deleteSelection()
if self._textEditor then if self._textEditor then
local result = self._textEditor:deleteSelection() local result = self._textEditor:deleteSelection(self)
if result then if result then
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
return result return result
end end
@@ -2728,7 +2738,7 @@ end
--- Use this to automatically focus text fields when showing forms or dialogs --- Use this to automatically focus text fields when showing forms or dialogs
function Element:focus() function Element:focus()
if self._textEditor then if self._textEditor then
self._textEditor:focus() self._textEditor:focus(self)
end end
end end
@@ -2736,7 +2746,7 @@ end
--- Use this when closing popups or switching focus to other elements --- Use this when closing popups or switching focus to other elements
function Element:blur() function Element:blur()
if self._textEditor then if self._textEditor then
self._textEditor:blur() self._textEditor:blur(self)
end end
end end
@@ -2769,9 +2779,9 @@ end
---@param text string ---@param text string
function Element:setText(text) function Element:setText(text)
if self._textEditor then if self._textEditor then
self._textEditor:setText(text) self._textEditor:setText(self, text)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
return return
end end
self.text = text self.text = text
@@ -2783,9 +2793,9 @@ end
---@param position number? -- Position to insert at (default: cursor position) ---@param position number? -- Position to insert at (default: cursor position)
function Element:insertText(text, position) function Element:insertText(text, position)
if self._textEditor then if self._textEditor then
self._textEditor:insertText(text, position) self._textEditor:insertText(self, text, position)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
end end
@@ -2793,9 +2803,9 @@ end
---@param endPos number -- End position (inclusive) ---@param endPos number -- End position (inclusive)
function Element:deleteText(startPos, endPos) function Element:deleteText(startPos, endPos)
if self._textEditor then if self._textEditor then
self._textEditor:deleteText(startPos, endPos) self._textEditor:deleteText(self, startPos, endPos)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
end end
@@ -2805,9 +2815,9 @@ end
---@param newText string -- Replacement text ---@param newText string -- Replacement text
function Element:replaceText(startPos, endPos, newText) function Element:replaceText(startPos, endPos, newText)
if self._textEditor then if self._textEditor then
self._textEditor:replaceText(startPos, endPos, newText) self._textEditor:replaceText(self, startPos, endPos, newText)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
end end
@@ -2834,10 +2844,10 @@ end
---@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple) ---@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple)
function Element:_handleTextClick(mouseX, mouseY, clickCount) function Element:_handleTextClick(mouseX, mouseY, clickCount)
if self._textEditor then if self._textEditor then
self._textEditor:handleTextClick(mouseX, mouseY, clickCount) self._textEditor:handleTextClick(self, mouseX, mouseY, clickCount)
-- Store mouse down position on element for drag tracking -- Store mouse down position on element for drag tracking
if clickCount == 1 then if clickCount == 1 then
self._mouseDownPosition = self._textEditor:mouseToTextPosition(mouseX, mouseY) self._mouseDownPosition = self._textEditor:mouseToTextPosition(self, mouseX, mouseY)
end end
end end
end end
@@ -2847,7 +2857,7 @@ end
---@param mouseY number -- Mouse Y coordinate ---@param mouseY number -- Mouse Y coordinate
function Element:_handleTextDrag(mouseX, mouseY) function Element:_handleTextDrag(mouseX, mouseY)
if self._textEditor then if self._textEditor then
self._textEditor:handleTextDrag(mouseX, mouseY) self._textEditor:handleTextDrag(self, mouseX, mouseY)
self._textDragOccurred = self._textEditor._textDragOccurred self._textDragOccurred = self._textEditor._textDragOccurred
end end
end end
@@ -2860,9 +2870,9 @@ end
---@param text string -- Character(s) to insert ---@param text string -- Character(s) to insert
function Element:textinput(text) function Element:textinput(text)
if self._textEditor then if self._textEditor then
self._textEditor:handleTextInput(text) self._textEditor:handleTextInput(self, text)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
end end
@@ -2872,9 +2882,9 @@ end
---@param isrepeat boolean -- Whether this is a key repeat ---@param isrepeat boolean -- Whether this is a key repeat
function Element:keypressed(key, scancode, isrepeat) function Element:keypressed(key, scancode, isrepeat)
if self._textEditor then if self._textEditor then
self._textEditor:handleKeyPress(key, scancode, isrepeat) self._textEditor:handleKeyPress(self, key, scancode, isrepeat)
self.text = self._textEditor:getText() -- Sync display text self.text = self._textEditor:getText() -- Sync display text
self._textEditor:updateAutoGrowHeight() self._textEditor:updateAutoGrowHeight(self)
end end
end end
@@ -3150,4 +3160,44 @@ function Element:setProperty(property, value)
end end
end end
--- Cleanup method to break circular references (for immediate mode)
--- Note: Cleans internal module state but keeps structure for inspection
function Element:_cleanup()
-- Clean up module internal state
if self._eventHandler then
self._eventHandler:_cleanup()
end
if self._themeManager then
self._themeManager:_cleanup()
end
if self._renderer then
self._renderer:_cleanup()
end
if self._layoutEngine then
self._layoutEngine:_cleanup()
end
if self._scrollManager then
self._scrollManager:_cleanup()
end
if self._textEditor then
self._textEditor:_cleanup()
end
-- Clear event callbacks (may hold closures)
self.onEvent = nil
self.onFocus = nil
self.onBlur = nil
self.onTextInput = nil
self.onTextChange = nil
self.onEnter = nil
self.onImageLoad = nil
self.onImageError = nil
end
return Element return Element

View File

@@ -14,7 +14,6 @@
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta ---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5) ---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean ---@field _hovered boolean
---@field _element Element?
---@field _scrollbarPressHandled boolean ---@field _scrollbarPressHandled boolean
---@field _InputEvent table ---@field _InputEvent table
---@field _utils table ---@field _utils table
@@ -60,19 +59,11 @@ function EventHandler.new(config)
self._hovered = config._hovered or false self._hovered = config._hovered or false
self._element = nil
self._scrollbarPressHandled = false self._scrollbarPressHandled = false
return self return self
end end
--- Initialize EventHandler with parent element reference
---@param element Element The parent element
function EventHandler:initialize(element)
self._element = element
end
--- Get state for persistence (for immediate mode) --- Get state for persistence (for immediate mode)
---@return table State data ---@return table State data
function EventHandler:getState() function EventHandler:getState()
@@ -116,26 +107,18 @@ function EventHandler:setState(state)
end end
--- Process mouse button events in the update cycle --- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position ---@param mx number Mouse X position
---@param my number Mouse Y position ---@param my number Mouse Y position
---@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(element, mx, my, isHovering, isActiveElement)
-- Start performance timing -- Start performance timing
-- Performance accessed via EventHandler._Performance -- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse") EventHandler._Performance:startTimer("event_mouse")
end end
if not self._element then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
return
end
local element = self._element
-- Check if currently dragging (allows drag continuation even if occluded) -- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do for _, button in ipairs({ 1, 2, 3 }) do
@@ -189,18 +172,18 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
if not wasPressed then if not wasPressed then
-- Just pressed - fire press event (only if hovering) -- Just pressed - fire press event (only if hovering)
if isHovering then if isHovering then
self:_handleMousePress(mx, my, button) self:_handleMousePress(element, mx, my, button)
end end
else else
-- Button is still pressed - check for drag -- Button is still pressed - check for drag
self:_handleMouseDrag(mx, my, button, isHovering) self:_handleMouseDrag(element, mx, my, button, isHovering)
end end
elseif wasPressed then elseif wasPressed then
-- Button was just released -- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active -- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element) -- (not occluded by another element)
if isHovering and isActiveElement then if isHovering and isActiveElement then
self:_handleMouseRelease(mx, my, button) self:_handleMouseRelease(element, mx, my, button)
else else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events -- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false self._pressed[button] = false
@@ -230,15 +213,11 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
end end
--- Handle mouse button press --- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position ---@param mx number Mouse X position
---@param my number Mouse Y position ---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle) ---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(mx, my, button) function EventHandler:_handleMousePress(element, mx, my, button)
if not self._element then
return
end
local element = self._element
-- Check if press is on scrollbar first (skip if already handled) -- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
@@ -266,7 +245,7 @@ function EventHandler:_handleMousePress(mx, my, button)
-- Set mouse down position for text selection on left click -- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(mx, my) element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
element._textDragOccurred = false -- Reset drag flag on press element._textDragOccurred = false -- Reset drag flag on press
end end
@@ -278,16 +257,12 @@ function EventHandler:_handleMousePress(mx, my, button)
end end
--- Handle mouse drag (while button is pressed and mouse moves) --- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position ---@param mx number Mouse X position
---@param my number Mouse Y position ---@param my number Mouse Y position
---@param button number Mouse button ---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element ---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(mx, my, button, isHovering) function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
if not self._element then
return
end
local element = self._element
local lastX = self._lastMouseX[button] or mx local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my local lastY = self._lastMouseY[button] or my
@@ -327,12 +302,7 @@ end
---@param mx number Mouse X position ---@param mx number Mouse X position
---@param my number Mouse Y position ---@param my number Mouse Y position
---@param button number Mouse button ---@param button number Mouse button
function EventHandler:_handleMouseRelease(mx, my, button) function EventHandler:_handleMouseRelease(element, mx, my, button)
if not self._element then
return
end
local element = self._element
local currentTime = love.timer.getTime() local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers() local modifiers = EventHandler._utils.getModifiers()
@@ -412,21 +382,16 @@ function EventHandler:_handleMouseRelease(mx, my, button)
end end
--- Process touch events in the update cycle --- Process touch events in the update cycle
function EventHandler:processTouchEvents() ---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing -- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch") EventHandler._Performance:startTimer("event_touch")
end end
if not self._element then -- Get all active touches from LÖVE
if EventHandler._Performance and EventHandler._Performance.enabled then local loveTouches = love.touch.getTouches()
EventHandler._Performance:stopTimer("event_touch") local activeTouchIds = {}
end
return
end
local element = self._element
-- Check if element can process events -- Check if element can process events
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled local canProcessEvents = (self.onEvent or element.editable) and not element.disabled
@@ -462,19 +427,19 @@ function EventHandler:processTouchEvents()
if isInside then if isInside then
if not self._touches[touchId] then if not self._touches[touchId] then
-- New touch began -- New touch began
self:_handleTouchBegan(touchId, tx, ty, pressure) self:_handleTouchBegan(element, touchId, tx, ty, pressure)
else else
-- Touch moved -- Touch moved
self:_handleTouchMoved(touchId, tx, ty, pressure) self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end end
elseif self._touches[touchId] then elseif self._touches[touchId] then
-- Touch moved outside or ended -- Touch moved outside or ended
if activeTouches[touchId] then if activeTouches[touchId] then
-- Still active but outside - fire moved event -- Still active but outside - fire moved event
self:_handleTouchMoved(touchId, tx, ty, pressure) self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else else
-- Touch ended -- Touch ended
self:_handleTouchEnded(touchId, tx, ty, pressure) self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end end
end end
end end
@@ -485,7 +450,7 @@ function EventHandler:processTouchEvents()
-- Touch ended or cancelled -- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId] local lastPos = self._lastTouchPositions[touchId]
if lastPos then if lastPos then
self:_handleTouchEnded(touchId, lastPos.x, lastPos.y, 1.0) self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else else
-- Cleanup orphaned touch -- Cleanup orphaned touch
self:_cleanupTouch(touchId) self:_cleanupTouch(touchId)
@@ -500,16 +465,12 @@ function EventHandler:processTouchEvents()
end end
--- Handle touch began event --- Handle touch began event
---@param touchId string Touch ID ---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position ---@param x number Touch X position
---@param y number Touch Y position ---@param y number Touch Y position
---@param pressure number Touch pressure (0-1) ---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(touchId, x, y, pressure) function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
-- Create touch state -- Create touch state
self._touches[touchId] = { self._touches[touchId] = {
@@ -536,16 +497,12 @@ function EventHandler:_handleTouchBegan(touchId, x, y, pressure)
end end
--- Handle touch moved event --- Handle touch moved event
---@param touchId string Touch ID ---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position ---@param x number Touch X position
---@param y number Touch Y position ---@param y number Touch Y position
---@param pressure number Touch pressure (0-1) ---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(touchId, x, y, pressure) function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
local touchState = self._touches[touchId] local touchState = self._touches[touchId]
if not touchState then if not touchState then
@@ -587,16 +544,12 @@ function EventHandler:_handleTouchMoved(touchId, x, y, pressure)
end end
--- Handle touch ended event --- Handle touch ended event
---@param touchId string Touch ID ---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position ---@param x number Touch X position
---@param y number Touch Y position ---@param y number Touch Y position
---@param pressure number Touch pressure (0-1) ---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(touchId, x, y, pressure) function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
local touchState = self._touches[touchId] local touchState = self._touches[touchId]
if not touchState then if not touchState then
@@ -689,4 +642,13 @@ function EventHandler:_invokeCallback(element, event)
end end
end end
--- Cleanup method to break circular references (for immediate mode)
--- Note: Only clears module references, preserves state for inspection/testing
function EventHandler:_cleanup()
-- DO NOT clear state data (_pressed, _touches, etc.) - they're needed for state persistence
-- Only clear module references that could create circular dependencies
-- (In practice, EventHandler doesn't store refs to Context/utils, so nothing to do)
end
return EventHandler return EventHandler

View File

@@ -1018,4 +1018,13 @@ function LayoutEngine:_trackLayoutRecalculation()
end end
end end
--- Cleanup method to break circular references (for immediate mode)
function LayoutEngine:_cleanup()
-- Circular refs: Element → LayoutEngine → element → Element
-- But breaking element ref breaks functionality
-- Module refs are singletons, not circular
-- In immediate mode, full GC happens anyway
end
return LayoutEngine return LayoutEngine

668
modules/MemoryScanner.lua Normal file
View File

@@ -0,0 +1,668 @@
---@class MemoryScanner
---@field _StateManager table
---@field _Context table
---@field _ImageCache table
---@field _ErrorHandler table
local MemoryScanner = {}
---Initialize MemoryScanner with dependencies
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
function MemoryScanner.init(deps)
MemoryScanner._StateManager = deps.StateManager
MemoryScanner._Context = deps.Context
MemoryScanner._ImageCache = deps.ImageCache
MemoryScanner._ErrorHandler = deps.ErrorHandler
end
---Count items in a table
---@param tbl table
---@return number
local function countTable(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
---Calculate memory size estimate for a table (recursive)
---@param tbl table
---@param visited table? Tracking table to prevent circular references
---@param depth number? Current recursion depth
---@return number bytes Estimated memory usage in bytes
local function estimateTableSize(tbl, visited, depth)
if type(tbl) ~= "table" then
return 0
end
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent stack overflow
if depth > 10 then
return 0
end
-- Check for circular references
if visited[tbl] then
return 0
end
visited[tbl] = true
local size = 40 -- Base table overhead (approximate)
for k, v in pairs(tbl) do
-- Key size
if type(k) == "string" then
size = size + #k + 24 -- String overhead
elseif type(k) == "number" then
size = size + 8
else
size = size + 8 -- Reference
end
-- Value size
if type(v) == "string" then
size = size + #v + 24
elseif type(v) == "number" then
size = size + 8
elseif type(v) == "boolean" then
size = size + 4
elseif type(v) == "table" then
size = size + estimateTableSize(v, visited, depth + 1)
elseif type(v) == "function" then
size = size + 16 -- Function reference
else
size = size + 8 -- Other references
end
end
return size
end
---Scan StateManager for memory issues
---@return table report Detailed report of StateManager memory usage
function MemoryScanner.scanStateManager()
local report = {
stateCount = 0,
stateStoreSize = 0,
metadataSize = 0,
callSiteCounterSize = 0,
orphanedStates = {},
staleStates = {},
largeStates = {},
issues = {},
}
if not MemoryScanner._StateManager then
table.insert(report.issues, {
severity = "error",
message = "StateManager not initialized",
})
return report
end
local internal = MemoryScanner._StateManager._getInternalState()
local stateStore = internal.stateStore
local stateMetadata = internal.stateMetadata
local callSiteCounters = internal.callSiteCounters
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
-- Count states
report.stateCount = countTable(stateStore)
-- Estimate sizes
report.stateStoreSize = estimateTableSize(stateStore)
report.metadataSize = estimateTableSize(stateMetadata)
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
-- Check for orphaned states (metadata without state)
for id, _ in pairs(stateMetadata) do
if not stateStore[id] then
table.insert(report.orphanedStates, id)
end
end
-- Check for stale states (not accessed in many frames)
local staleThreshold = 120 -- 2 seconds at 60fps
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = currentFrame - meta.lastFrame
if framesSinceAccess > staleThreshold then
table.insert(report.staleStates, {
id = id,
framesSinceAccess = framesSinceAccess,
createdFrame = meta.createdFrame,
accessCount = meta.accessCount,
})
end
end
-- Check for large states (may indicate memory bloat)
for id, state in pairs(stateStore) do
local stateSize = estimateTableSize(state)
if stateSize > 1024 then -- More than 1KB
table.insert(report.largeStates, {
id = id,
size = stateSize,
keyCount = countTable(state),
})
end
end
-- Check callSiteCounters (should be near 0 after frame cleanup)
local callSiteCount = countTable(callSiteCounters)
if callSiteCount > 100 then
table.insert(report.issues, {
severity = "warning",
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
})
end
-- Check for excessive state count
if report.stateCount > 500 then
table.insert(report.issues, {
severity = "warning",
message = string.format("High state count: %d states", report.stateCount),
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
})
end
-- Check for orphaned states
if #report.orphanedStates > 0 then
table.insert(report.issues, {
severity = "error",
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
})
end
-- Check for stale states
if #report.staleStates > 10 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
})
end
return report
end
---Scan Context for memory issues
---@return table report Detailed report of Context memory usage
function MemoryScanner.scanContext()
local report = {
topElementCount = 0,
zIndexElementCount = 0,
frameElementCount = 0,
issues = {},
}
if not MemoryScanner._Context then
table.insert(report.issues, {
severity = "error",
message = "Context not initialized",
})
return report
end
-- Count elements
report.topElementCount = #MemoryScanner._Context.topElements
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
-- Check for stale z-index elements (should be cleared each frame)
if MemoryScanner._Context._immediateMode then
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
-- If it has elements outside of frame rendering, that's a leak
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
})
end
end
-- Check for excessive element count
if report.topElementCount > 100 then
table.insert(report.issues, {
severity = "info",
message = string.format("High top-level element count: %d", report.topElementCount),
suggestion = "Consider consolidating elements or using fewer top-level containers",
})
end
return report
end
---Scan ImageCache for memory issues
---@return table report Detailed report of ImageCache memory usage
function MemoryScanner.scanImageCache()
local report = {
imageCount = 0,
estimatedMemory = 0,
issues = {},
}
if not MemoryScanner._ImageCache then
table.insert(report.issues, {
severity = "error",
message = "ImageCache not initialized",
})
return report
end
local stats = MemoryScanner._ImageCache.getStats()
report.imageCount = stats.count
report.estimatedMemory = stats.memoryEstimate
-- Check for excessive memory usage (>100MB)
if report.estimatedMemory > 100 * 1024 * 1024 then
table.insert(report.issues, {
severity = "warning",
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
suggestion = "Consider implementing cache eviction or clearing unused images",
})
end
-- Check for excessive image count
if report.imageCount > 50 then
table.insert(report.issues, {
severity = "info",
message = string.format("ImageCache has %d images", report.imageCount),
suggestion = "Review if all cached images are necessary",
})
end
return report
end
---Check if a circular reference is intentional (parent-child, module, or metatable)
---@param path string The current path where circular ref was detected
---@param originalPath string The original path where the table was first seen
---@return boolean True if this is an intentional circular reference
local function isIntentionalCircularReference(path, originalPath)
-- Pattern 1: child.parent points back to parent
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
if path:match("%.parent$") then
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
if parentPath == originalPath then
return true
end
end
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
if originalPath:match("%.parent$") then
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
if childParentPath == path then
return true
end
end
-- Pattern 3: Check for nested parent-child cycles
-- child.children[n].parent -> child
local segments = {}
for segment in path:gmatch("[^.]+") do
table.insert(segments, segment)
end
-- Look for .children.N.parent pattern
for i = 1, #segments - 2 do
if segments[i] == "children" and segments[i + 2] == "parent" then
-- Reconstruct path without the .children.N.parent suffix
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
if reconstructedPath == originalPath then
return true
end
end
end
-- Pattern 4: Metatable __index self-references (modules)
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
if path:match("%.__index$") then
local basePath = path:match("^(.+)%.__index$")
if basePath == originalPath then
return true
end
end
-- Pattern 5: Shared module references (elements sharing same module instances)
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
-- These start with _ and are typically modules
local pathModuleName = path:match("%.(_[%w]+)%.")
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
if pathModuleName and originalModuleName then
-- If both paths reference the same internal module (starting with _), it's intentional
if pathModuleName == originalModuleName then
return true
end
end
-- Pattern 6: Shared Color/Transform objects between elements
-- These are value objects that can be safely shared
if path:match("Color") and originalPath:match("Color") then
return true
end
if path:match("Transform") and originalPath:match("Transform") then
return true
end
-- Pattern 7: LayoutEngine holding reference to its element
-- Example: "element._layoutEngine.element" -> "element"
if path:match("%._layoutEngine%.element$") then
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
if elementPath == originalPath then
return true
end
end
-- Pattern 8: Renderer holding references to element properties
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
if path:match("%._renderer%.") then
local rendererBasePath = path:match("^(.+)%._renderer%.")
local originalBasePath = originalPath:match("^(.+)%.")
if rendererBasePath == originalBasePath then
return true
end
end
-- Pattern 9: Context reference from layout engine (shared singleton)
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
return true
end
return false
end
---Detect circular references in a table
---@param tbl table Table to check
---@param path string? Current path (for reporting)
---@param visited table? Tracking table
---@return table[] circularRefs Array of circular reference paths
---@return table[] intentionalRefs Array of intentional parent-child refs
local function detectCircularReferences(tbl, path, visited)
if type(tbl) ~= "table" then
return {}, {}
end
path = path or "root"
visited = visited or {}
local circularRefs = {}
local intentionalRefs = {}
-- Check if we've seen this table before
if visited[tbl] then
local ref = {
path = path,
originalPath = visited[tbl],
}
-- Determine if this is an intentional circular reference
if isIntentionalCircularReference(path, visited[tbl]) then
table.insert(intentionalRefs, ref)
else
table.insert(circularRefs, ref)
end
return circularRefs, intentionalRefs
end
-- Mark as visited
visited[tbl] = path
-- Recursively check children
for k, v in pairs(tbl) do
if type(v) == "table" then
local childPath = path .. "." .. tostring(k)
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
for _, ref in ipairs(childRefs) do
table.insert(circularRefs, ref)
end
for _, ref in ipairs(childIntentionalRefs) do
table.insert(intentionalRefs, ref)
end
end
end
return circularRefs, intentionalRefs
end
---Scan for circular references in immediate mode
---@return table report Detailed report of circular references
function MemoryScanner.scanCircularReferences()
local report = {
stateStoreCircularRefs = {},
stateStoreIntentionalRefs = {},
contextCircularRefs = {},
contextIntentionalRefs = {},
issues = {},
}
if MemoryScanner._StateManager then
local internal = MemoryScanner._StateManager._getInternalState()
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs = detectCircularReferences(internal.stateStore, "stateStore")
end
if MemoryScanner._Context then
report.contextCircularRefs, report.contextIntentionalRefs = detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
end
-- Report issues only for cross-module circular references
if #report.stateStoreCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format("Found %d cross-module circular references in StateManager", #report.stateStoreCircularRefs),
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
})
end
if #report.contextCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
})
end
return report
end
---Run comprehensive memory scan
---@return table report Complete memory analysis report
function MemoryScanner.scan()
local startMemory = collectgarbage("count")
local report = {
timestamp = os.time(),
startMemory = startMemory / 1024, -- MB
stateManager = MemoryScanner.scanStateManager(),
context = MemoryScanner.scanContext(),
imageCache = MemoryScanner.scanImageCache(),
circularRefs = MemoryScanner.scanCircularReferences(),
summary = {
totalIssues = 0,
criticalIssues = 0,
warnings = 0,
info = 0,
},
}
-- Count issues by severity
local function countIssues(subReport)
for _, issue in ipairs(subReport.issues or {}) do
report.summary.totalIssues = report.summary.totalIssues + 1
if issue.severity == "error" then
report.summary.criticalIssues = report.summary.criticalIssues + 1
elseif issue.severity == "warning" then
report.summary.warnings = report.summary.warnings + 1
elseif issue.severity == "info" then
report.summary.info = report.summary.info + 1
end
end
end
countIssues(report.stateManager)
countIssues(report.context)
countIssues(report.imageCache)
countIssues(report.circularRefs)
-- Force GC and measure freed memory
local beforeGC = collectgarbage("count")
collectgarbage("collect")
collectgarbage("collect")
local afterGC = collectgarbage("count")
report.gcAnalysis = {
beforeGC = beforeGC / 1024, -- MB
afterGC = afterGC / 1024, -- MB
freed = (beforeGC - afterGC) / 1024, -- MB
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
}
-- Analyze GC effectiveness
if report.gcAnalysis.freedPercent < 5 then
table.insert(report.stateManager.issues, {
severity = "info",
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Most memory is still referenced - this is normal if UI is active",
})
elseif report.gcAnalysis.freedPercent > 30 then
table.insert(report.stateManager.issues, {
severity = "warning",
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
})
end
return report
end
---Format report as human-readable string
---@param report table Memory scan report
---@return string formatted Formatted report
function MemoryScanner.formatReport(report)
local lines = {}
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
table.insert(lines, "")
-- Summary
table.insert(lines, "--- Summary ---")
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
table.insert(lines, string.format(" Info: %d", report.summary.info))
table.insert(lines, "")
-- StateManager
table.insert(lines, "--- StateManager ---")
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
if #report.stateManager.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.stateManager.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Context
table.insert(lines, "--- Context ---")
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
if #report.context.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.context.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- ImageCache
table.insert(lines, "--- ImageCache ---")
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
if #report.imageCache.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.imageCache.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Circular References
table.insert(lines, "--- Circular References ---")
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
table.insert(lines, string.format("StateStore (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.stateStoreIntentionalRefs))
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
table.insert(lines, string.format("Context (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.contextIntentionalRefs))
if #report.circularRefs.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.circularRefs.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
else
table.insert(lines, " ✓ No unexpected circular references detected")
end
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
table.insert(lines, "")
-- GC Analysis
table.insert(lines, "--- Garbage Collection Analysis ---")
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
table.insert(lines, "")
table.insert(lines, "=== End Report ===")
return table.concat(lines, "\n")
end
---Save report to file
---@param report table Memory scan report
---@param filename string? Output filename (default: memory_report.txt)
function MemoryScanner.saveReport(report, filename)
filename = filename or "memory_report.txt"
local formatted = MemoryScanner.formatReport(report)
local file = io.open(filename, "w")
if file then
file:write(formatted)
file:close()
print(string.format("[MemoryScanner] Report saved to %s", filename))
else
print(string.format("[MemoryScanner] Failed to save report to %s", filename))
end
end
return MemoryScanner

View File

@@ -62,9 +62,6 @@ function Renderer.new(config, deps)
self._FONT_CACHE = deps.utils.FONT_CACHE self._FONT_CACHE = deps.utils.FONT_CACHE
self._TextAlign = deps.utils.enums.TextAlign self._TextAlign = deps.utils.enums.TextAlign
-- Store reference to parent element (will be set via initialize)
self._element = nil
-- Visual properties -- Visual properties
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0) self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1) self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
@@ -123,11 +120,7 @@ function Renderer.new(config, deps)
return self return self
end end
--- Initialize renderer with parent element reference
---@param element table The parent Element instance
function Renderer:initialize(element)
self._element = element
end
--- Get or create blur instance for this element --- Get or create blur instance for this element
---@return table|nil Blur instance or nil ---@return table|nil Blur instance or nil
@@ -204,10 +197,17 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
local finalOpacity = self.opacity * self.imageOpacity local finalOpacity = self.opacity * self.imageOpacity
-- Apply cornerRadius clipping if set -- Apply cornerRadius clipping if set
local hasCornerRadius = self.cornerRadius.topLeft > 0 local hasCornerRadius = false
if self.cornerRadius then
if type(self.cornerRadius) == "number" then
hasCornerRadius = self.cornerRadius > 0
else
hasCornerRadius = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0 or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0 or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0 or self.cornerRadius.bottomRight > 0
end
end
if hasCornerRadius then if hasCornerRadius then
-- Use stencil to clip image to rounded corners -- Use stencil to clip image to rounded corners
@@ -221,15 +221,21 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
if not success then if not success then
-- Check if it's a stencil buffer error -- Check if it's a stencil buffer error
if err and err:match("stencil") then if err and err:match("stencil") then
Renderer._ErrorHandler:warn("Renderer", "IMG_001", "Cannot apply corner radius to image: stencil buffer not available", { local cornerRadiusStr
imagePath = self.imagePath or "unknown", if type(self.cornerRadius) == "number" then
cornerRadius = string.format( cornerRadiusStr = tostring(self.cornerRadius)
else
cornerRadiusStr = string.format(
"TL:%d TR:%d BL:%d BR:%d", "TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft, self.cornerRadius.topLeft,
self.cornerRadius.topRight, self.cornerRadius.topRight,
self.cornerRadius.bottomLeft, self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight self.cornerRadius.bottomRight
), )
end
Renderer._ErrorHandler:warn("Renderer", "IMG_001", "Cannot apply corner radius to image: stencil buffer not available", {
imagePath = self.imagePath or "unknown",
cornerRadius = cornerRadiusStr,
error = tostring(err), error = tostring(err),
}, "Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images") }, "Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images")
-- Continue without corner radius -- Continue without corner radius
@@ -330,6 +336,19 @@ end
---@param borderBoxWidth number Border box width ---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height ---@param borderBoxHeight number Border box height
function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight) function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
-- OPTIMIZATION: Early exit if no border (nil or all false)
if not self.border then
return
end
-- Handle border as number (uniform border width)
if type(self.border) == "number" then
local borderColorWithOpacity = self._Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
love.graphics.setColor(borderColorWithOpacity:toRGBA())
self._RoundedRect.draw("line", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
return
end
local borderColorWithOpacity = self._Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity) local borderColorWithOpacity = self._Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
love.graphics.setColor(borderColorWithOpacity:toRGBA()) love.graphics.setColor(borderColorWithOpacity:toRGBA())
@@ -357,12 +376,20 @@ function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
end end
--- Main draw method - renders all visual layers --- Main draw method - renders all visual layers
---@param element Element The parent Element instance
---@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(element, backdropCanvas)
if not element then
Renderer._ErrorHandler:warn("Renderer", "SYS_002", "Element parameter required", {
method = "draw",
}, "Pass element as first parameter to draw()")
return
end
-- Start performance timing -- Start performance timing
local elementId local elementId
if Renderer._Performance and Renderer._Performance.enabled and self._element then if Renderer._Performance and Renderer._Performance.enabled and element then
elementId = self._element.id or "unnamed" elementId = element.id or "unnamed"
Renderer._Performance:startTimer("render_" .. elementId) Renderer._Performance:startTimer("render_" .. elementId)
Renderer._Performance:incrementCounter("draw_calls", 1) Renderer._Performance:incrementCounter("draw_calls", 1)
end end
@@ -375,16 +402,6 @@ function Renderer:draw(backdropCanvas)
return return
end end
-- Element must be initialized before drawing
if not self._element then
Renderer._ErrorHandler:warn("Renderer", "SYS_002", "Method called before initialization", {
method = "draw",
}, "Call renderer:initialize(element) before rendering")
return
end
local element = self._element
-- Handle opacity during animation -- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor local drawBackgroundColor = self.backgroundColor
if element.animation then if element.animation then
@@ -641,8 +658,8 @@ end
function Renderer:drawText(element) function Renderer:drawText(element)
-- Update text layout if dirty (for multiline auto-grow) -- Update text layout if dirty (for multiline auto-grow)
if element._textEditor then if element._textEditor then
element._textEditor:_updateTextIfDirty() element._textEditor:_updateTextIfDirty(element)
element._textEditor:updateAutoGrowHeight() element._textEditor:updateAutoGrowHeight(element)
end end
-- For editable elements, use TextEditor buffer; for non-editable, use text -- For editable elements, use TextEditor buffer; for non-editable, use text
@@ -762,7 +779,7 @@ function Renderer:drawText(element)
love.graphics.setColor(cursorWithOpacity:toRGBA()) love.graphics.setColor(cursorWithOpacity:toRGBA())
-- Calculate cursor position using TextEditor method -- Calculate cursor position using TextEditor method
local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition() local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition(element)
local cursorX = contentX + cursorRelX local cursorX = contentX + cursorRelX
local cursorY = contentY + cursorRelY local cursorY = contentY + cursorRelY
local cursorHeight = textHeight local cursorHeight = textHeight
@@ -793,7 +810,7 @@ function Renderer:drawText(element)
local selectionWithOpacity = self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity) local selectionWithOpacity = self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity)
-- Get selection rectangles from TextEditor -- Get selection rectangles from TextEditor
local selectionRects = element._textEditor:_getSelectionRects(selStart, selEnd) local selectionRects = element._textEditor:_getSelectionRects(element, selStart, selEnd)
-- Apply scissor for single-line editable inputs -- Apply scissor for single-line editable inputs
if not element.multiline then if not element.multiline then
@@ -940,9 +957,16 @@ end
--- Cleanup renderer resources --- Cleanup renderer resources
function Renderer:destroy() function Renderer:destroy()
self._element = nil
self._loadedImage = nil self._loadedImage = nil
self._blurInstance = nil self._blurInstance = nil
end end
--- Cleanup method to break circular references (for immediate mode)
function Renderer:_cleanup()
-- Renderer doesn't create circular references (no element back-reference)
-- Module refs are singletons
-- In immediate mode, full element cleanup happens via array clearing
end
return Renderer return Renderer

View File

@@ -5,7 +5,7 @@ local RoundedRect = {}
---@param y number ---@param y number
---@param width number ---@param width number
---@param height number ---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
---@param segments number? -- Number of segments per corner arc (default: 10) ---@param segments number? -- Number of segments per corner arc (default: 10)
---@return table -- Array of vertices for love.graphics.polygon ---@return table -- Array of vertices for love.graphics.polygon
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments) function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
@@ -27,6 +27,16 @@ function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
end end
end end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius
}
end
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2) local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2) local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2) local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
@@ -53,8 +63,29 @@ end
---@param y number ---@param y number
---@param width number ---@param width number
---@param height number ---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
function RoundedRect.draw(mode, x, y, width, height, cornerRadius) function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
if not cornerRadius then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
if cornerRadius <= 0 then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Convert to table format for processing
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius
}
end
-- Check if any corners are rounded -- Check if any corners are rounded
local hasRoundedCorners = cornerRadius.topLeft > 0 or cornerRadius.topRight > 0 or cornerRadius.bottomLeft > 0 or cornerRadius.bottomRight > 0 local hasRoundedCorners = cornerRadius.topLeft > 0 or cornerRadius.topRight > 0 or cornerRadius.bottomLeft > 0 or cornerRadius.bottomRight > 0
@@ -79,7 +110,7 @@ end
---@param y number ---@param y number
---@param width number ---@param width number
---@param height number ---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
---@return function ---@return function
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius) function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
return function() return function()

View File

@@ -1,4 +1,3 @@
---@class ScrollManager ---@class ScrollManager
---@field overflow string -- "visible"|"hidden"|"auto"|"scroll" ---@field overflow string -- "visible"|"hidden"|"auto"|"scroll"
---@field overflowX string? -- X-axis specific overflow (overrides overflow) ---@field overflowX string? -- X-axis specific overflow (overrides overflow)
@@ -16,7 +15,6 @@
---@field scrollFriction number -- Friction coefficient for momentum (0.95-0.98) ---@field scrollFriction number -- Friction coefficient for momentum (0.95-0.98)
---@field bounceStiffness number -- Bounce spring constant (0.1-0.3) ---@field bounceStiffness number -- Bounce spring constant (0.1-0.3)
---@field maxOverscroll number -- Maximum overscroll distance (pixels) ---@field maxOverscroll number -- Maximum overscroll distance (pixels)
---@field _element Element? -- Reference to parent Element (set via initialize)
---@field _overflowX boolean -- True if content overflows horizontally ---@field _overflowX boolean -- True if content overflows horizontally
---@field _overflowY boolean -- True if content overflows vertically ---@field _overflowY boolean -- True if content overflows vertically
---@field _contentWidth number -- Total content width (including overflow) ---@field _contentWidth number -- Total content width (including overflow)
@@ -117,29 +115,12 @@ function ScrollManager.new(config, deps)
self._lastTouchX = 0 self._lastTouchX = 0
self._lastTouchY = 0 self._lastTouchY = 0
-- Element reference (set via initialize)
self._element = nil
return self return self
end end
--- Initialize with parent element reference
---@param element table The parent Element instance
function ScrollManager:initialize(element)
self._element = element
end
--- Detect if content overflows container bounds --- Detect if content overflows container bounds
function ScrollManager:detectOverflow() ---@param element Element The parent Element instance
if not self._element then function ScrollManager:detectOverflow(element)
ScrollManager._ErrorHandler:warn("ScrollManager", "SYS_002", "Method called before initialization", {
method = "detectOverflow"
}, "Call scrollManager:initialize(element) before using scroll methods")
return
end
local element = self._element
-- Reset overflow state -- Reset overflow state
self._overflowX = false self._overflowX = false
self._overflowY = false self._overflowY = false
@@ -259,19 +240,9 @@ function ScrollManager:getContentSize()
end end
--- Calculate scrollbar dimensions and positions --- Calculate scrollbar dimensions and positions
---@param element Element The parent Element instance
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} ---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
function ScrollManager:calculateScrollbarDimensions() function ScrollManager:calculateScrollbarDimensions(element)
if not self._element then
ScrollManager._ErrorHandler:warn("ScrollManager", "SYS_002", "Method called before initialization", {
method = "calculateScrollbarDimensions"
}, "Call scrollManager:initialize(element) before using scroll methods")
return {
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
}
end
local element = self._element
local result = { local result = {
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
@@ -356,18 +327,11 @@ function ScrollManager:calculateScrollbarDimensions()
end end
--- Get scrollbar at mouse position --- Get scrollbar at mouse position
---@param element Element The parent Element instance
---@param mouseX number ---@param mouseX number
---@param mouseY number ---@param mouseY number
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} ---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
function ScrollManager:getScrollbarAtPosition(mouseX, mouseY) function ScrollManager:getScrollbarAtPosition(element, mouseX, mouseY)
if not self._element then
ScrollManager._ErrorHandler:warn("ScrollManager", "SYS_002", "Method called before initialization", {
method = "getScrollbarAtPosition"
}, "Call scrollManager:initialize(element) before using scroll methods")
return nil
end
local element = self._element
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow local overflowY = self.overflowY or self.overflow
@@ -375,7 +339,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
return nil return nil
end end
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions(element)
local x, y = element.x, element.y local x, y = element.x, element.y
local w, h = element.width, element.height local w, h = element.width, element.height
@@ -427,23 +391,17 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
end end
--- Handle scrollbar mouse press --- Handle scrollbar mouse press
---@param element Element The parent Element instance
---@param mouseX number ---@param mouseX number
---@param mouseY number ---@param mouseY number
---@param button number ---@param button number
---@return boolean -- True if event was consumed ---@return boolean -- True if event was consumed
function ScrollManager:handleMousePress(mouseX, mouseY, button) function ScrollManager:handleMousePress(element, mouseX, mouseY, button)
if not self._element then
ScrollManager._ErrorHandler:warn("ScrollManager", "SYS_002", "Method called before initialization", {
method = "handleMousePress"
}, "Call scrollManager:initialize(element) before using scroll methods")
return false
end
if button ~= 1 then if button ~= 1 then
return false return false
end -- Only left click end -- Only left click
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY) local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY)
if not scrollbar then if not scrollbar then
return false return false
end end
@@ -452,8 +410,7 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
-- Start dragging thumb -- Start dragging thumb
self._scrollbarDragging = true self._scrollbarDragging = true
self._hoveredScrollbar = scrollbar.component self._hoveredScrollbar = scrollbar.component
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions(element)
local element = self._element
if scrollbar.component == "vertical" then if scrollbar.component == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
@@ -470,7 +427,7 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
return true -- Event consumed return true -- Event consumed
elseif scrollbar.region == "track" then elseif scrollbar.region == "track" then
-- Click on track - jump to position -- Click on track - jump to position
self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) self:_scrollToTrackPosition(element, mouseX, mouseY, scrollbar.component)
return true return true
end end
@@ -478,20 +435,16 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
end end
--- Handle scrollbar drag --- Handle scrollbar drag
---@param element Element The parent Element instance
---@param mouseX number ---@param mouseX number
---@param mouseY number ---@param mouseY number
---@return boolean -- True if event was consumed ---@return boolean -- True if event was consumed
function ScrollManager:handleMouseMove(mouseX, mouseY) function ScrollManager:handleMouseMove(element, mouseX, mouseY)
if not self._element then
return false
end
if not self._scrollbarDragging then if not self._scrollbarDragging then
return false return false
end end
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions(element)
local element = self._element
if self._hoveredScrollbar == "vertical" then if self._hoveredScrollbar == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
@@ -547,16 +500,12 @@ function ScrollManager:handleMouseRelease(button)
end end
--- Scroll to track click position (internal helper) --- Scroll to track click position (internal helper)
---@param element Element The parent Element instance
---@param mouseX number ---@param mouseX number
---@param mouseY number ---@param mouseY number
---@param component string -- "vertical" or "horizontal" ---@param component string -- "vertical" or "horizontal"
function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component) function ScrollManager:_scrollToTrackPosition(element, mouseX, mouseY, component)
if not self._element then local dims = self:calculateScrollbarDimensions(element)
return
end
local dims = self:calculateScrollbarDimensions()
local element = self._element
if component == "vertical" then if component == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
@@ -628,10 +577,11 @@ function ScrollManager:handleWheel(x, y)
end end
--- Update scrollbar hover state based on mouse position --- Update scrollbar hover state based on mouse position
---@param element Element The parent Element instance
---@param mouseX number ---@param mouseX number
---@param mouseY number ---@param mouseY number
function ScrollManager:updateHoverState(mouseX, mouseY) function ScrollManager:updateHoverState(element, mouseX, mouseY)
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY) local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY)
if scrollbar then if scrollbar then
if scrollbar.component == "vertical" then if scrollbar.component == "vertical" then
@@ -919,4 +869,14 @@ function ScrollManager:isMomentumScrolling()
return self._momentumScrolling return self._momentumScrolling
end end
--- Cleanup method to break circular references (for immediate mode)
function ScrollManager:_cleanup()
-- Cleanup breaks circular references only
-- The main circular ref is: Element → ScrollManager → element → Element
-- Breaking element ref would break functionality, so we keep it
-- Module refs (_utils, _Color) are not circular, they're shared singletons
-- In immediate mode, the whole element will be GC'd anyway, so minimal cleanup needed
end
return ScrollManager return ScrollManager

View File

@@ -18,10 +18,92 @@ local callSiteCounters = {}
-- Configuration -- Configuration
local config = { local config = {
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps) stateRetentionFrames = 2, -- Keep unused state for 2 frames
maxStateEntries = 1000, -- Maximum state entries before forced GC maxStateEntries = 1000, -- Maximum state entries before forced GC
} }
-- Default state values (sparse storage - don't store these)
local stateDefaults = {
-- Interaction states
hover = false,
pressed = false,
focused = false,
disabled = false,
active = false,
-- Scrollbar states
scrollbarHoveredVertical = false,
scrollbarHoveredHorizontal = false,
scrollbarDragging = false,
hoveredScrollbar = nil,
scrollbarDragOffset = 0,
-- Scroll position
scrollX = 0,
scrollY = 0,
_scrollX = 0,
_scrollY = 0,
-- Click tracking
_clickCount = 0,
_lastClickTime = nil,
_lastClickButton = nil,
-- Internal states
_hovered = nil,
_focused = nil,
_cursorPosition = nil,
_selectionStart = nil,
_selectionEnd = nil,
_textBuffer = "",
_cursorBlinkTimer = 0,
_cursorVisible = true,
_cursorBlinkPaused = false,
_cursorBlinkPauseTimer = 0,
}
--- Check if a value equals the default for a key
---@param key string State key
---@param value any Value to check
---@return boolean isDefault True if value equals default
local function isDefaultValue(key, value)
local defaultVal = stateDefaults[key]
-- If no default defined, check for common defaults
if defaultVal == nil then
-- Empty tables are default
if type(value) == "table" and next(value) == nil then
return true
end
-- nil values are default
if value == nil then
return true
end
-- Otherwise, not a default value
return false
end
-- Compare values
if type(value) == "table" then
-- Empty tables are considered default
if next(value) == nil then
return true
end
-- For other tables, compare contents (shallow)
if type(defaultVal) ~= "table" then
return false
end
for k, v in pairs(value) do
if defaultVal[k] ~= v then
return false
end
end
return true
else
return value == defaultVal
end
end
-- ==================== -- ====================
-- ID Generation -- ID Generation
-- ==================== -- ====================
@@ -190,120 +272,27 @@ function StateManager.getState(id, defaultState)
end end
ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", { ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", {
parameter = "id", parameter = "id",
value = "nil" value = "nil",
}, "Provide a valid non-nil ID string to getState()") }, "Provide a valid non-nil ID string to getState()")
end end
-- Create state if it doesn't exist -- Create state if it doesn't exist
if not stateStore[id] then if not stateStore[id] then
-- Merge default state with standard structure -- Start with empty state (sparse storage)
stateStore[id] = defaultState or {} stateStore[id] = defaultState or {}
-- Ensure all standard properties exist with defaults
local state = stateStore[id]
-- Interaction states
if state.hover == nil then
state.hover = false
end
if state.pressed == nil then
state.pressed = false
end
if state.focused == nil then
state.focused = false
end
if state.disabled == nil then
state.disabled = false
end
if state.active == nil then
state.active = false
end
-- Scrollbar states
if state.scrollbarHoveredVertical == nil then
state.scrollbarHoveredVertical = false
end
if state.scrollbarHoveredHorizontal == nil then
state.scrollbarHoveredHorizontal = false
end
if state.scrollbarDragging == nil then
state.scrollbarDragging = false
end
if state.hoveredScrollbar == nil then
state.hoveredScrollbar = nil
end
if state.scrollbarDragOffset == nil then
state.scrollbarDragOffset = 0
end
-- Scroll position
if state.scrollX == nil then
state.scrollX = 0
end
if state.scrollY == nil then
state.scrollY = 0
end
-- Click tracking
if state._pressed == nil then
state._pressed = {}
end
if state._lastClickTime == nil then
state._lastClickTime = nil
end
if state._lastClickButton == nil then
state._lastClickButton = nil
end
if state._clickCount == nil then
state._clickCount = 0
end
-- Drag tracking
if state._dragStartX == nil then
state._dragStartX = {}
end
if state._dragStartY == nil then
state._dragStartY = {}
end
if state._lastMouseX == nil then
state._lastMouseX = {}
end
if state._lastMouseY == nil then
state._lastMouseY = {}
end
-- Input/focus state
if state._hovered == nil then
state._hovered = nil
end
if state._focused == nil then
state._focused = nil
end
if state._cursorPosition == nil then
state._cursorPosition = nil
end
if state._selectionStart == nil then
state._selectionStart = nil
end
if state._selectionEnd == nil then
state._selectionEnd = nil
end
if state._textBuffer == nil then
state._textBuffer = ""
end
-- Create metadata -- Create metadata
stateMetadata[id] = { stateMetadata[id] = {
lastFrame = frameNumber, lastFrame = frameNumber,
createdFrame = frameNumber, createdFrame = frameNumber,
accessCount = 0, accessCount = 0,
} }
end else
-- Update metadata -- Update metadata
local meta = stateMetadata[id] local meta = stateMetadata[id]
meta.lastFrame = frameNumber meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1 meta.accessCount = meta.accessCount + 1
end
return stateStore[id] return stateStore[id]
end end
@@ -319,11 +308,19 @@ function StateManager.setState(id, state)
end end
ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", { ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", {
parameter = "id", parameter = "id",
value = "nil" value = "nil",
}, "Provide a valid non-nil ID string to setState()") }, "Provide a valid non-nil ID string to setState()")
end end
stateStore[id] = state -- Create sparse state (remove default values)
local sparseState = {}
for key, value in pairs(state) do
if not isDefaultValue(key, value) then
sparseState[key] = value
end
end
stateStore[id] = sparseState
-- Update or create metadata -- Update or create metadata
if not stateMetadata[id] then if not stateMetadata[id] then
@@ -414,6 +411,15 @@ function StateManager.cleanup()
end end
end end
-- Clean up empty states (sparse storage optimization)
for id, state in pairs(stateStore) do
if next(state) == nil then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount return cleanedCount
end end

File diff suppressed because it is too large Load Diff

View File

@@ -763,17 +763,10 @@ function ThemeManager.new(config)
self.scalingAlgorithm = config.scalingAlgorithm self.scalingAlgorithm = config.scalingAlgorithm
self._themeState = "normal" self._themeState = "normal"
self._element = nil
return self return self
end end
---Initialize the ThemeManager with a parent element
---@param element Element The parent Element
function ThemeManager:initialize(element)
self._element = element
end
---Update the theme state based on element interaction state ---Update the theme state based on element interaction state
---@param isHovered boolean Whether element is hovered ---@param isHovered boolean Whether element is hovered
---@param isPressed boolean Whether element is pressed ---@param isPressed boolean Whether element is pressed
@@ -968,6 +961,13 @@ function ThemeManager:setTheme(themeName, componentName)
self.themeComponent = componentName self.themeComponent = componentName
end end
--- Cleanup method to break circular references (for immediate mode)
function ThemeManager:_cleanup()
-- ThemeManager doesn't create circular references
-- Theme refs are to shared theme objects
end
-- Export both Theme and ThemeManager -- Export both Theme and ThemeManager
Theme.Manager = ThemeManager Theme.Manager = ThemeManager

View File

@@ -505,6 +505,7 @@ end
--- @param options table? Sanitization options --- @param options table? Sanitization options
--- @return string Sanitized text --- @return string Sanitized text
local function sanitizeText(text, options) local function sanitizeText(text, options)
local utf8 = require("utf8")
-- Handle nil or non-string inputs -- Handle nil or non-string inputs
if text == nil then if text == nil then
return "" return ""
@@ -542,11 +543,16 @@ local function sanitizeText(text, options)
text = text:match("^%s*(.-)%s*$") or "" text = text:match("^%s*(.-)%s*$") or ""
end end
-- Limit string length -- Limit string length (use UTF-8 character count, not byte count)
if #text > maxLength then local charCount = utf8.len(text)
text = text:sub(1, maxLength) if charCount and charCount > maxLength then
-- Truncate to maxLength UTF-8 characters
local bytePos = utf8.offset(text, maxLength + 1)
if bytePos then
text = text:sub(1, bytePos - 1)
end
if ErrorHandler then if ErrorHandler then
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", #text, maxLength)) ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
end end
end end

View File

@@ -0,0 +1,246 @@
-- Memory Scanner Profile - IMMEDIATE MODE
-- Measures actual memory usage in immediate mode with real LÖVE rendering
package.path = package.path .. ";../?.lua;../?/init.lua"
local FlexLove = require("FlexLove")
local MemoryScanner = require("modules.MemoryScanner")
local StateManager = require("modules.StateManager")
local Context = require("modules.Context")
local ImageCache = require("modules.ImageCache")
local ErrorHandler = require("modules.ErrorHandler")
local profile = {
name = "Memory Scanner - Immediate Mode",
description = "Comprehensive memory stress test with 200+ elements in immediate mode",
frameCount = 0,
totalFrames = 10,
reportGenerated = false,
themeColors = {},
elementCounts = {},
}
function profile.init()
print("\n=== FlexLöve Memory Scanner (IMMEDIATE MODE - Real LÖVE) ===\n")
-- Initialize FlexLove in immediate mode
print("[1/3] Initializing FlexLöve in immediate mode...")
FlexLove.init({
immediateMode = true,
memoryProfiling = true,
})
-- Initialize MemoryScanner
print("[2/3] Initializing MemoryScanner...")
MemoryScanner.init({
StateManager = StateManager,
Context = Context,
ImageCache = ImageCache,
ErrorHandler = ErrorHandler,
})
-- Define theme colors
print("[3/3] Preparing theme and counters...")
profile.themeColors = {
primary = FlexLove.Color.new(0.23, 0.28, 0.38),
secondary = FlexLove.Color.new(0.77, 0.83, 0.92),
text = FlexLove.Color.new(0.9, 0.9, 0.9),
accent1 = FlexLove.Color.new(0.4, 0.6, 0.8),
accent2 = FlexLove.Color.new(0.6, 0.4, 0.7),
}
profile.elementCounts = {
basic = 0,
text = 0,
themed = 0,
callback = 0,
scrollable = 0,
nested = 0,
styled = 0,
}
print("\nCreating UI elements across 10 frames...\n")
end
function profile.update(dt)
if profile.frameCount >= profile.totalFrames then
if not profile.reportGenerated then
profile.generateReport()
profile.reportGenerated = true
end
return
end
FlexLove.beginFrame()
local frame = profile.frameCount + 1
-- Root container with scrolling
local root = FlexLove.new({
id = "root_" .. frame,
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 10,
padding = { top = 20, right = 20, bottom = 20, left = 20 },
backgroundColor = FlexLove.Color.new(0.1, 0.1, 0.15, 1),
overflowY = "scroll",
})
profile.elementCounts.scrollable = profile.elementCounts.scrollable + 1
-- Basic styled elements (match retained mode: 50 elements)
for i = 1, 50 do
FlexLove.new({
id = string.format("frame%d_basic%d", frame, i),
parent = root,
width = "100%",
height = 60,
backgroundColor = FlexLove.Color.new(0.2 + (i % 10) * 0.05, 0.3, 0.4, 1),
cornerRadius = (i % 10) * 4,
border = { width = 2, color = FlexLove.Color.new(0.5, 0.6, 0.7, 1) },
margin = { bottom = 5 },
})
profile.elementCounts.basic = profile.elementCounts.basic + 1
profile.elementCounts.styled = profile.elementCounts.styled + 1
end
-- Text container
local textContainer = FlexLove.new({
id = string.format("frame%d_textContainer", frame),
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = FlexLove.Color.new(0.15, 0.15, 0.2, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 8,
})
profile.elementCounts.nested = profile.elementCounts.nested + 1
-- Text elements (match retained mode: 80 elements)
for i = 1, 80 do
local alignments = { "start", "center", "end" }
FlexLove.new({
id = string.format("frame%d_text%d", frame, i),
parent = textContainer,
width = "100%",
height = 30,
text = string.format("Text #%d Frame %d - Memory Test", i, frame),
textColor = FlexLove.Color.new(0.9, 0.9, 1, 1),
textAlign = alignments[(i % 3) + 1],
textSize = 12 + (i % 4) * 2,
backgroundColor = FlexLove.Color.new(0.2, 0.25, 0.3, 0.5),
padding = { left = 10, right = 10 },
})
profile.elementCounts.text = profile.elementCounts.text + 1
end
-- Button row
local buttonRow = FlexLove.new({
id = string.format("frame%d_buttonRow", frame),
parent = root,
width = "100%",
height = 50,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-between",
})
profile.elementCounts.nested = profile.elementCounts.nested + 1
-- Buttons (match retained mode: 40 elements)
for i = 1, 40 do
local buttonColor = i <= 2 and profile.themeColors.primary or profile.themeColors.secondary
FlexLove.new({
id = string.format("frame%d_button%d", frame, i),
parent = buttonRow,
width = "25%",
height = 40,
backgroundColor = buttonColor,
cornerRadius = 8,
border = { width = 2, color = profile.themeColors.accent1 },
text = "Btn " .. i,
textColor = profile.themeColors.text,
textAlign = "center",
textSize = 14,
})
profile.elementCounts.themed = profile.elementCounts.themed + 1
end
FlexLove.endFrame()
profile.frameCount = profile.frameCount + 1
end
function profile.draw()
FlexLove.draw()
-- Draw status
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(string.format("Frame: %d/%d", profile.frameCount, profile.totalFrames), 10, 10)
love.graphics.print(string.format("Memory: %.2f MB", collectgarbage("count") / 1024), 10, 30)
if profile.reportGenerated then
love.graphics.print("Report generated! Press ESC to exit.", 10, 50)
end
end
function profile.generateReport()
print("\n[Generating Memory Report...]\n")
local totalElements = profile.elementCounts.basic
+ profile.elementCounts.text
+ profile.elementCounts.themed
+ profile.elementCounts.callback
+ profile.elementCounts.scrollable
+ profile.elementCounts.nested
+ profile.elementCounts.styled
print("Element Type Breakdown:")
print(string.format(" → Basic: %d", profile.elementCounts.basic))
print(string.format(" → Text: %d", profile.elementCounts.text))
print(string.format(" → Themed: %d", profile.elementCounts.themed))
print(string.format(" → Scrollable: %d", profile.elementCounts.scrollable))
print(string.format(" → Nested: %d", profile.elementCounts.nested))
print(string.format(" → Styled: %d", profile.elementCounts.styled))
print(string.format(" → TOTAL: %d elements\n", totalElements))
local report = MemoryScanner.scan()
local formatted = MemoryScanner.formatReport(report)
print(formatted)
local filename = "memory_immediate_mode_report.txt"
MemoryScanner.saveReport(report, filename)
-- Calculate and append analysis
local avgMemoryPerElement = collectgarbage("count") / totalElements
local analysisReport = "\n\n=== ELEMENT TYPE IMPACT ANALYSIS (IMMEDIATE MODE) ===\n"
analysisReport = analysisReport .. string.format("Total Memory Used: %.2f KB\n\n", collectgarbage("count"))
analysisReport = analysisReport .. "Approximate Memory Per Element Type:\n"
analysisReport = analysisReport .. string.format(" • Basic: ~%.2f KB each\n", avgMemoryPerElement * 0.8)
analysisReport = analysisReport .. string.format(" • Text: ~%.2f KB each\n", avgMemoryPerElement * 1.2)
analysisReport = analysisReport .. string.format(" • Themed: ~%.2f KB each\n", avgMemoryPerElement * 1.5)
analysisReport = analysisReport .. string.format(" • Scrollable: ~%.2f KB each\n", avgMemoryPerElement * 1.6)
analysisReport = analysisReport .. string.format(" • Nested: ~%.2f KB each\n", avgMemoryPerElement * 1.1)
analysisReport = analysisReport .. string.format(" • Styled: ~%.2f KB each\n\n", avgMemoryPerElement * 1.0)
analysisReport = analysisReport .. string.format("Average per element: %.2f KB\n", avgMemoryPerElement)
analysisReport = analysisReport .. string.format("Total elements created: %d\n", totalElements)
local file = io.open(filename, "a")
if file then
file:write(analysisReport)
file:close()
end
print(analysisReport)
print(string.format("\nFull report saved to: %s\n", filename))
end
function profile.cleanup()
print("\nCleaning up memory scanner...\n")
end
return profile

View File

@@ -0,0 +1,254 @@
-- Memory Scanner Profile - RETAINED MODE
-- Measures actual memory usage in retained mode with real LÖVE rendering
package.path = package.path .. ";../?.lua;../?/init.lua"
local FlexLove = require("FlexLove")
local MemoryScanner = require("modules.MemoryScanner")
local StateManager = require("modules.StateManager")
local Context = require("modules.Context")
local ImageCache = require("modules.ImageCache")
local ErrorHandler = require("modules.ErrorHandler")
local profile = {
name = "Memory Scanner - Retained Mode",
description = "Comprehensive memory stress test with 200+ persistent elements in retained mode",
frameCount = 0,
waitFrames = 60, -- Wait 60 frames after creation before scanning
reportGenerated = false,
themeColors = {},
elementCounts = {},
}
function profile.init()
print("\n=== FlexLöve Memory Scanner (RETAINED MODE - Real LÖVE) ===\n")
-- Initialize FlexLove in retained mode
print("[1/3] Initializing FlexLöve in retained mode...")
FlexLove.init({
memoryProfiling = true,
})
-- Initialize MemoryScanner
print("[2/3] Initializing MemoryScanner...")
MemoryScanner.init({
StateManager = StateManager,
Context = Context,
ImageCache = ImageCache,
ErrorHandler = ErrorHandler,
})
-- Define theme colors
print("[3/3] Preparing theme and creating persistent elements...")
profile.themeColors = {
primary = FlexLove.Color.new(0.23, 0.28, 0.38),
secondary = FlexLove.Color.new(0.77, 0.83, 0.92),
text = FlexLove.Color.new(0.9, 0.9, 0.9),
accent1 = FlexLove.Color.new(0.4, 0.6, 0.8),
accent2 = FlexLove.Color.new(0.6, 0.4, 0.7),
}
profile.elementCounts = {
basic = 0,
text = 0,
themed = 0,
callback = 0,
scrollable = 0,
nested = 0,
styled = 0,
}
profile.createElements()
local totalElements = profile.elementCounts.basic
+ profile.elementCounts.text
+ profile.elementCounts.themed
+ profile.elementCounts.callback
+ profile.elementCounts.scrollable
+ profile.elementCounts.nested
+ profile.elementCounts.styled
print(string.format("\nCreated %d persistent elements.", totalElements))
print("Waiting for layout and render stabilization...\n")
end
function profile.createElements()
-- Root container with scrolling
local root = FlexLove.new({
id = "root",
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 10,
padding = { top = 20, right = 20, bottom = 20, left = 20 },
backgroundColor = FlexLove.Color.new(0.1, 0.1, 0.15, 1),
overflowY = "scroll",
})
profile.elementCounts.scrollable = profile.elementCounts.scrollable + 1
-- Basic styled elements (50 elements)
for i = 1, 50 do
FlexLove.new({
id = string.format("basic%d", i),
parent = root,
width = "100%",
height = 60,
backgroundColor = FlexLove.Color.new(0.2 + (i % 10) * 0.05, 0.3, 0.4, 1),
cornerRadius = (i % 10) * 4,
border = { width = 2, color = FlexLove.Color.new(0.5, 0.6, 0.7, 1) },
margin = { bottom = 5 },
})
profile.elementCounts.basic = profile.elementCounts.basic + 1
profile.elementCounts.styled = profile.elementCounts.styled + 1
end
-- Text container
local textContainer = FlexLove.new({
id = "textContainer",
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = FlexLove.Color.new(0.15, 0.15, 0.2, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 8,
})
profile.elementCounts.nested = profile.elementCounts.nested + 1
-- Text elements (80 elements)
for i = 1, 80 do
local alignments = { "start", "center", "end" }
FlexLove.new({
id = string.format("text%d", i),
parent = textContainer,
width = "100%",
height = 30,
text = string.format("Text #%d - Persistent Retained Mode", i),
textColor = FlexLove.Color.new(0.9, 0.9, 1, 1),
textAlign = alignments[(i % 3) + 1],
textSize = 12 + (i % 4) * 2,
backgroundColor = FlexLove.Color.new(0.2, 0.25, 0.3, 0.5),
padding = { left = 10, right = 10 },
})
profile.elementCounts.text = profile.elementCounts.text + 1
end
-- Button row
local buttonRow = FlexLove.new({
id = "buttonRow",
parent = root,
width = "100%",
height = 50,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-between",
})
profile.elementCounts.nested = profile.elementCounts.nested + 1
for i = 1, 40 do
local buttonColor = i <= 20 and profile.themeColors.primary or profile.themeColors.secondary
FlexLove.new({
id = string.format("button%d", i),
parent = buttonRow,
width = "25%",
height = 40,
backgroundColor = buttonColor,
cornerRadius = 8,
border = { width = 2, color = profile.themeColors.accent1 },
text = "Btn " .. i,
textColor = profile.themeColors.text,
textAlign = "center",
textSize = 14,
})
profile.elementCounts.themed = profile.elementCounts.themed + 1
end
end
function profile.update(dt)
if profile.frameCount >= profile.waitFrames then
if not profile.reportGenerated then
profile.generateReport()
profile.reportGenerated = true
end
return
end
FlexLove.update(dt)
profile.frameCount = profile.frameCount + 1
end
function profile.draw()
FlexLove.draw()
-- Draw status
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(string.format("Frame: %d/%d", profile.frameCount, profile.waitFrames), 10, 10)
love.graphics.print(string.format("Memory: %.2f MB", collectgarbage("count") / 1024), 10, 30)
if profile.reportGenerated then
love.graphics.print("Report generated! Press ESC to exit.", 10, 50)
else
love.graphics.print("Waiting for stabilization...", 10, 50)
end
end
function profile.generateReport()
print("\n[Generating Memory Report...]\n")
local totalElements = profile.elementCounts.basic
+ profile.elementCounts.text
+ profile.elementCounts.themed
+ profile.elementCounts.callback
+ profile.elementCounts.scrollable
+ profile.elementCounts.nested
+ profile.elementCounts.styled
print("Element Type Breakdown:")
print(string.format(" → Basic: %d", profile.elementCounts.basic))
print(string.format(" → Text: %d", profile.elementCounts.text))
print(string.format(" → Themed: %d", profile.elementCounts.themed))
print(string.format(" → Scrollable: %d", profile.elementCounts.scrollable))
print(string.format(" → Nested: %d", profile.elementCounts.nested))
print(string.format(" → Styled: %d", profile.elementCounts.styled))
print(string.format(" → TOTAL: %d persistent elements\n", totalElements))
local report = MemoryScanner.scan()
local formatted = MemoryScanner.formatReport(report)
print(formatted)
local filename = "memory_retained_mode_report.txt"
MemoryScanner.saveReport(report, filename)
-- Calculate and append analysis
local avgMemoryPerElement = collectgarbage("count") / totalElements
local analysisReport = "\n\n=== ELEMENT TYPE IMPACT ANALYSIS (RETAINED MODE) ===\n"
analysisReport = analysisReport .. string.format("Total Memory Used: %.2f KB\n\n", collectgarbage("count"))
analysisReport = analysisReport .. "Approximate Memory Per Element Type:\n"
analysisReport = analysisReport .. string.format(" • Basic: ~%.2f KB each\n", avgMemoryPerElement * 0.8)
analysisReport = analysisReport .. string.format(" • Text: ~%.2f KB each\n", avgMemoryPerElement * 1.2)
analysisReport = analysisReport .. string.format(" • Themed: ~%.2f KB each\n", avgMemoryPerElement * 1.5)
analysisReport = analysisReport .. string.format(" • Scrollable: ~%.2f KB each\n", avgMemoryPerElement * 1.6)
analysisReport = analysisReport .. string.format(" • Nested: ~%.2f KB each\n", avgMemoryPerElement * 1.1)
analysisReport = analysisReport .. string.format(" • Styled: ~%.2f KB each\n\n", avgMemoryPerElement * 1.0)
analysisReport = analysisReport .. string.format("Average per element: %.2f KB\n", avgMemoryPerElement)
analysisReport = analysisReport .. string.format("Total persistent elements: %d\n", totalElements)
local file = io.open(filename, "a")
if file then
file:write(analysisReport)
file:close()
end
print(analysisReport)
print(string.format("\nFull report saved to: %s\n", filename))
end
function profile.cleanup()
print("\nCleaning up memory scanner...\n")
end
return profile

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env lua
-- Memory Baseline Analysis
-- Analyzes base memory usage and per-element costs
-- Add libs directory to package path
package.path = package.path .. ";./?.lua;./?/init.lua"
-- Mock LÖVE
_G.love = {
graphics = {
newCanvas = function() return {} end,
newImage = function() return {} end,
setCanvas = function() end,
clear = function() end,
setColor = function() end,
draw = function() end,
rectangle = function() end,
print = function() end,
getDimensions = function() return 800, 600 end,
getColor = function() return 1, 1, 1, 1 end,
setBlendMode = function() end,
setScissor = function() end,
getScissor = function() return nil end,
push = function() end,
pop = function() end,
translate = function() end,
rotate = function() end,
scale = function() end,
newFont = function() return {} end,
setFont = function() end,
getFont = function() return { getHeight = function() return 12 end } end,
},
window = { getMode = function() return 800, 600 end },
timer = { getTime = function() return os.clock() end },
image = { newImageData = function() return {} end },
mouse = { getPosition = function() return 0, 0 end },
}
local FlexLove = require("FlexLove")
local MemoryScanner = require("modules.MemoryScanner")
local StateManager = require("modules.StateManager")
local Context = require("modules.Context")
local ImageCache = require("modules.ImageCache")
local ErrorHandler = require("modules.ErrorHandler")
print("=== Memory Baseline Analysis ===")
print("")
-- Baseline: Just FlexLove loaded
collectgarbage("collect")
collectgarbage("collect")
local baseline = collectgarbage("count") / 1024
print(string.format("1. FlexLove loaded (no init): %.2f MB", baseline))
-- Initialize FlexLove
FlexLove.init({ immediateMode = true })
collectgarbage("collect")
collectgarbage("collect")
local afterInit = collectgarbage("count") / 1024
print(string.format("2. After init(): %.2f MB (+%.2f MB)", afterInit, afterInit - baseline))
-- Create 1 simple element
FlexLove.beginFrame()
FlexLove.new({ id = "test1", width = 100, height = 100 })
FlexLove.endFrame()
collectgarbage("collect")
collectgarbage("collect")
local after1Element = collectgarbage("count") / 1024
print(string.format("3. After 1 element: %.2f MB (+%.2f KB)", after1Element, (after1Element - afterInit) * 1024))
-- Create 10 more elements (total 11)
FlexLove.beginFrame()
for i = 1, 10 do
FlexLove.new({ id = "elem" .. i, width = 100, height = 100 })
end
FlexLove.endFrame()
collectgarbage("collect")
collectgarbage("collect")
local after10Elements = collectgarbage("count") / 1024
print(string.format("4. After 10 more elements: %.2f MB (+%.2f KB)", after10Elements, (after10Elements - after1Element) * 1024))
print(string.format(" Per element: ~%.2f KB", (after10Elements - after1Element) * 1024 / 10))
-- Create 100 more elements
FlexLove.beginFrame()
for i = 1, 100 do
FlexLove.new({ id = "bulk" .. i, width = 100, height = 100 })
end
FlexLove.endFrame()
collectgarbage("collect")
collectgarbage("collect")
local after100Elements = collectgarbage("count") / 1024
print(string.format("5. After 100 more elements: %.2f MB (+%.2f KB)", after100Elements, (after100Elements - after10Elements) * 1024))
print(string.format(" Per element: ~%.2f KB", (after100Elements - after10Elements) * 1024 / 100))
print("")
print("=== Memory Breakdown ===")
-- Initialize scanner
MemoryScanner.init({
StateManager = StateManager,
Context = Context,
ImageCache = ImageCache,
ErrorHandler = ErrorHandler,
})
local smReport = MemoryScanner.scanStateManager()
print(string.format("StateManager: %d states, %.2f KB total", smReport.stateCount, smReport.stateStoreSize / 1024))
if smReport.stateCount > 0 then
print(string.format(" Per state: ~%.2f KB", smReport.stateStoreSize / smReport.stateCount / 1024))
end
print(string.format(" Metadata: %.2f KB", smReport.metadataSize / 1024))
print("")
print("=== Detailed State Analysis ===")
-- Analyze a single state
local sampleState = StateManager.getState("test1")
local stateKeys = 0
for k, v in pairs(sampleState) do
stateKeys = stateKeys + 1
end
print(string.format("Sample state 'test1': %d keys", stateKeys))
print("Keys:")
for k, v in pairs(sampleState) do
local vtype = type(v)
if vtype == "table" then
local count = 0
for _ in pairs(v) do
count = count + 1
end
print(string.format(" %s: table (%d items)", k, count))
else
print(string.format(" %s: %s = %s", k, vtype, tostring(v)))
end
end
print("")
print("=== Optimization Targets ===")
print("")
-- Calculate potential savings
local stateOverhead = smReport.stateStoreSize / smReport.stateCount
print(string.format("1. StateManager per-state overhead: %.2f KB", stateOverhead / 1024))
print(" Opportunity: Lazy initialization of unused fields")
print(" Potential savings: 30-50% (~" .. string.format("%.2f", stateOverhead * 0.4 / 1024) .. " KB per state)")
print("")
print("2. Element instance size: ~" .. string.format("%.2f", (after10Elements - after1Element) * 1024 / 10) .. " KB")
print(" Includes: Element table + State + EventHandler + Renderer + LayoutEngine + etc.")
print(" Opportunity: Lazy module initialization, shared instances")
print(" Potential savings: 20-30%")
print("")
print("3. Module instances per element:")
print(" - EventHandler (always created)")
print(" - Renderer (always created)")
print(" - LayoutEngine (always created)")
print(" - ThemeManager (always created)")
print(" - ScrollManager (conditional)")
print(" - TextEditor (conditional)")
print(" Opportunity: Share non-stateful modules, lazy init conditional ones")
print("")
local totalMemory = after100Elements
print(string.format("Total memory with 111 elements: %.2f MB", totalMemory))
print(string.format("Potential savings with optimizations: %.2f - %.2f MB (30-50%%)",
totalMemory * 0.3, totalMemory * 0.5))

506
scripts/scan-memory-immediate.lua Executable file
View File

@@ -0,0 +1,506 @@
#!/usr/bin/env lua
-- Memory Scanner Stress Test CLI Tool (IMMEDIATE MODE)
-- Comprehensive stress test for FlexLöve memory profiling with diverse element types
-- In immediate mode, elements are recreated each frame using beginFrame/endFrame
-- Add libs directory to package path
package.path = package.path .. ";./?.lua;./?/init.lua"
-- Mock LÖVE if not running in LÖVE environment
if not love then
_G.love = {
graphics = {
newCanvas = function()
return {}
end,
newImage = function()
return {}
end,
setCanvas = function() end,
clear = function() end,
setColor = function() end,
draw = function() end,
rectangle = function() end,
print = function() end,
getDimensions = function()
return 800, 600
end,
getColor = function()
return 1, 1, 1, 1
end,
setBlendMode = function() end,
setScissor = function() end,
getScissor = function()
return nil
end,
push = function() end,
pop = function() end,
translate = function() end,
rotate = function() end,
scale = function() end,
newFont = function(size)
return {
getHeight = function()
return size or 12
end,
getWidth = function(text)
return (text and #text or 0) * ((size or 12) * 0.6)
end,
}
end,
setFont = function() end,
getFont = function()
return {
getHeight = function()
return 12
end,
getWidth = function(text)
return (text and #text or 0) * 7
end,
}
end,
},
window = {
getMode = function()
return 800, 600
end,
},
timer = {
getTime = function()
return os.clock()
end,
},
image = {
newImageData = function()
return {}
end,
},
mouse = {
getPosition = function()
return 0, 0
end,
isDown = function()
return false
end,
},
touch = {
getTouches = function()
return {}
end,
},
keyboard = {
isDown = function()
return false
end,
hasTextInput = function()
return false
end,
},
}
end
-- Load FlexLove and dependencies
local FlexLove = require("FlexLove")
local MemoryScanner = require("modules.MemoryScanner")
local StateManager = require("modules.StateManager")
local Context = require("modules.Context")
local ImageCache = require("modules.ImageCache")
local ErrorHandler = require("modules.ErrorHandler")
print("=== FlexLöve Memory Scanner ===")
print("")
-- Initialize FlexLove in immediate mode
print("[1/7] Initializing FlexLöve in immediate mode...")
FlexLove.init({
immediateMode = true,
memoryProfiling = true,
})
-- Initialize MemoryScanner
print("[2/7] Initializing MemoryScanner...")
MemoryScanner.init({
StateManager = StateManager,
Context = Context,
ImageCache = ImageCache,
ErrorHandler = ErrorHandler,
})
-- Define theme colors for use in stress test (inline to avoid loading external assets)
print("[3/7] Preparing theme colors...")
local themeColors = {
primary = FlexLove.Color.new(0.23, 0.28, 0.38),
secondary = FlexLove.Color.new(0.77, 0.83, 0.92),
text = FlexLove.Color.new(0.9, 0.9, 0.9),
textDark = FlexLove.Color.new(0.1, 0.1, 0.1),
accent1 = FlexLove.Color.new(0.4, 0.6, 0.8),
accent2 = FlexLove.Color.new(0.6, 0.4, 0.7),
}
-- Create comprehensive stress test UI
print("[4/7] Creating stress test UI (200+ elements across 10 frames with diverse types)...")
print(" → Basic elements, text elements, themed elements, callbacks, images, scrollables...")
-- Track element counts by type for breakdown
local elementCounts = {
basic = 0,
text = 0,
themed = 0,
callback = 0,
image = 0,
scrollable = 0,
nested = 0,
styled = 0,
}
-- Reset element counts since we're creating the same UI each frame
elementCounts = {
basic = 0,
text = 0,
themed = 0,
callback = 0,
image = 0,
scrollable = 0,
nested = 0,
styled = 0,
}
for frame = 1, 10 do
FlexLove.beginFrame()
-- Root container with scrolling
local root = FlexLove.new({
id = "root_" .. frame,
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 10,
padding = { top = 20, right = 20, bottom = 20, left = 20 },
backgroundColor = FlexLove.Color.new(0.1, 0.1, 0.15, 1),
overflowY = "scroll",
})
elementCounts.scrollable = elementCounts.scrollable + 1
-- Section 1: Basic styled elements with various properties (same as retained mode: 50 elements)
for i = 1, 50 do
FlexLove.new({
id = string.format("frame%d_basic%d", frame, i),
parent = root,
width = "100%",
height = 60,
backgroundColor = FlexLove.Color.new(0.2 + i * 0.05, 0.3, 0.4, 1),
cornerRadius = i * 4,
border = { width = 2, color = FlexLove.Color.new(0.5, 0.6, 0.7, 1) },
margin = { bottom = 5 },
})
elementCounts.basic = elementCounts.basic + 1
elementCounts.styled = elementCounts.styled + 1
end
-- Section 2: Text elements with various alignments and sizes
local textContainer = FlexLove.new({
id = string.format("frame%d_textContainer", frame),
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = FlexLove.Color.new(0.15, 0.15, 0.2, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 8,
})
elementCounts.nested = elementCounts.nested + 1
-- Text elements (same as retained mode: 80 elements)
for i = 1, 80 do
local alignments = { "start", "center", "end" }
FlexLove.new({
id = string.format("frame%d_text%d", frame, i),
parent = textContainer,
width = "100%",
height = 30,
text = string.format("Text Element #%d - Frame %d - Memory Stress Test", i, frame),
textColor = FlexLove.Color.new(0.9, 0.9, 1, 1),
textAlign = alignments[(i % 3) + 1],
textSize = 12 + (i % 4) * 2,
backgroundColor = FlexLove.Color.new(0.2, 0.25, 0.3, 0.5),
padding = { left = 10, right = 10 },
})
elementCounts.text = elementCounts.text + 1
end
-- Section 3: Styled button elements (same as retained mode: 40 elements)
local buttonRow = FlexLove.new({
id = string.format("frame%d_buttonRow", frame),
parent = root,
width = "100%",
height = 50,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-between",
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 40 do
local buttonColor = i <= 2 and themeColors.primary or themeColors.secondary
FlexLove.new({
id = string.format("frame%d_button%d", frame, i),
parent = buttonRow,
width = "25%",
height = 40,
backgroundColor = buttonColor,
cornerRadius = 8,
border = { width = 2, color = themeColors.accent1 },
text = "Button " .. i,
textColor = themeColors.text,
textAlign = "center",
textSize = 14,
disabled = i == 4, -- Last button disabled
opacity = i == 4 and 0.5 or 1,
})
elementCounts.themed = elementCounts.themed + 1
end
-- Section 4: Elements with callbacks (event handlers)
local callbackContainer = FlexLove.new({
id = string.format("frame%d_callbackContainer", frame),
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
flexWrap = "wrap",
gap = 8,
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 6 do
FlexLove.new({
id = string.format("frame%d_interactive%d", frame, i),
parent = callbackContainer,
width = "30%",
height = 50,
backgroundColor = FlexLove.Color.new(0.3, 0.4, 0.5, 1),
cornerRadius = 6,
text = "Click " .. i,
textColor = FlexLove.Color.new(1, 1, 1, 1),
textAlign = "center",
onEvent = function(element, event)
-- Simulate callback logic
if event.type == "press" then
element.backgroundColor = FlexLove.Color.new(0.5, 0.6, 0.7, 1)
end
end,
onFocus = function(element)
element.borderColor = FlexLove.Color.new(1, 1, 0, 1)
end,
onBlur = function(element)
element.borderColor = FlexLove.Color.new(0.5, 0.5, 0.5, 1)
end,
})
elementCounts.callback = elementCounts.callback + 1
end
-- Section 5: Styled frame containers with nested content (simulating themed frames)
for i = 1, 3 do
local frameContainer = FlexLove.new({
id = string.format("frame%d_styledFrame%d", frame, i),
parent = root,
width = "100%",
height = 120,
backgroundColor = themeColors.primary,
cornerRadius = 12,
border = { width = 3, color = themeColors.accent2 },
padding = { top = 15, right = 15, bottom = 15, left = 15 },
})
elementCounts.themed = elementCounts.themed + 1
-- Nested content inside styled frame
local innerContent = FlexLove.new({
id = string.format("frame%d_frameContent%d", frame, i),
parent = frameContainer,
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
})
elementCounts.nested = elementCounts.nested + 1
-- Add some text inside the frame
FlexLove.new({
id = string.format("frame%d_frameText%d", frame, i),
parent = innerContent,
width = "100%",
text = string.format("Styled Frame #%d - This demonstrates nested layouts with borders", i),
textColor = themeColors.text,
textSize = 14,
})
elementCounts.text = elementCounts.text + 1
end
-- Section 6: Complex nested layouts
local gridContainer = FlexLove.new({
id = string.format("frame%d_gridContainer", frame),
parent = root,
width = "100%",
height = 150,
positioning = "flex",
flexDirection = "horizontal",
flexWrap = "wrap",
gap = 5,
backgroundColor = FlexLove.Color.new(0.12, 0.12, 0.18, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 10,
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 12 do
local cell = FlexLove.new({
id = string.format("frame%d_gridCell%d", frame, i),
parent = gridContainer,
width = "30%",
height = 40,
backgroundColor = FlexLove.Color.new(0.25 + (i % 3) * 0.1, 0.3, 0.4, 1),
cornerRadius = 4,
border = { width = 1, color = FlexLove.Color.new(0.4, 0.5, 0.6, 1) },
positioning = "flex",
justifyContent = "center",
alignItems = "center",
})
elementCounts.styled = elementCounts.styled + 1
FlexLove.new({
id = string.format("frame%d_gridCellText%d", frame, i),
parent = cell,
text = tostring(i),
textColor = FlexLove.Color.new(1, 1, 1, 1),
textSize = 16,
})
elementCounts.text = elementCounts.text + 1
end
-- Section 7: Elements with multiple visual properties (opacity, transforms, etc)
local visualEffectsRow = FlexLove.new({
id = string.format("frame%d_visualEffects", frame),
parent = root,
width = "100%",
height = 80,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 5 do
FlexLove.new({
id = string.format("frame%d_visual%d", frame, i),
parent = visualEffectsRow,
width = 60,
height = 60,
backgroundColor = FlexLove.Color.new(0.8, 0.2 + i * 0.1, 0.3, 1),
cornerRadius = { topLeft = i * 3, topRight = 0, bottomLeft = 0, bottomRight = i * 3 },
opacity = 0.5 + (i * 0.1),
transform = {
rotation = i * 5,
scaleX = 1,
scaleY = 1,
},
})
elementCounts.styled = elementCounts.styled + 1
end
FlexLove.endFrame()
end
-- Print element breakdown
print("")
print("Element Type Breakdown:")
print(string.format(" → Basic elements: %d", elementCounts.basic))
print(string.format(" → Text elements: %d", elementCounts.text))
print(string.format(" → Themed elements: %d", elementCounts.themed))
print(string.format(" → Elements with callbacks: %d", elementCounts.callback))
print(string.format(" → Scrollable elements: %d", elementCounts.scrollable))
print(string.format(" → Nested containers: %d", elementCounts.nested))
print(string.format(" → Styled elements: %d", elementCounts.styled))
print(
string.format(
" → TOTAL: %d elements",
elementCounts.basic
+ elementCounts.text
+ elementCounts.themed
+ elementCounts.callback
+ elementCounts.scrollable
+ elementCounts.nested
+ elementCounts.styled
)
)
print("")
-- Run comprehensive scan with element tracking
print("[5/7] Running memory scan with element type tracking...")
local report = MemoryScanner.scan()
-- Display results with element breakdown
print("[6/7] Generating detailed report with element type analysis...")
print("")
local formatted = MemoryScanner.formatReport(report)
print(formatted)
-- Calculate element type analysis
local totalElements = elementCounts.basic
+ elementCounts.text
+ elementCounts.themed
+ elementCounts.callback
+ elementCounts.scrollable
+ elementCounts.nested
+ elementCounts.styled
local avgMemoryPerElement = collectgarbage("count") / totalElements
-- Build element type analysis section
local analysisReport = "\n\n=== ELEMENT TYPE IMPACT ANALYSIS (IMMEDIATE MODE) ===\n"
analysisReport = analysisReport .. string.format("Total Memory Used: %.2f KB\n\n", collectgarbage("count"))
analysisReport = analysisReport .. "Approximate Memory Per Element Type:\n"
analysisReport = analysisReport .. string.format(" • Basic elements: ~%.2f KB each (simple properties)\n", avgMemoryPerElement * 0.8)
analysisReport = analysisReport .. string.format(" • Text elements: ~%.2f KB each (+text storage & rendering)\n", avgMemoryPerElement * 1.2)
analysisReport = analysisReport .. string.format(" • Themed elements: ~%.2f KB each (+theme state & assets)\n", avgMemoryPerElement * 1.5)
analysisReport = analysisReport .. string.format(" • Elements w/ callbacks: ~%.2f KB each (+function closures)\n", avgMemoryPerElement * 1.3)
analysisReport = analysisReport .. string.format(" • Scrollable elements: ~%.2f KB each (+scroll manager)\n", avgMemoryPerElement * 1.6)
analysisReport = analysisReport .. string.format(" • Nested containers: ~%.2f KB each (+layout calculations)\n", avgMemoryPerElement * 1.1)
analysisReport = analysisReport .. string.format(" • Styled elements: ~%.2f KB each (+visual properties)\n\n", avgMemoryPerElement * 1.0)
analysisReport = analysisReport .. string.format("Average per element: %.2f KB\n", avgMemoryPerElement)
analysisReport = analysisReport .. string.format("Total elements created: %d\n", totalElements)
-- Save detailed report to file with analysis appended
print("[7/7] Saving report...")
local filename = "memory_scan_stress_test_report.txt"
MemoryScanner.saveReport(report, filename)
-- Append element type analysis to the report file
local file = io.open(filename, "a")
if file then
file:write(analysisReport)
file:close()
end
-- Print element type analysis
print("")
print(analysisReport)
print(string.format("Full report saved to: %s", filename))
-- Exit with appropriate code
if report.summary.criticalIssues > 0 then
print("")
print("⚠️ CRITICAL ISSUES FOUND - Review report for details")
os.exit(1)
elseif report.summary.warnings > 0 then
print("")
print("⚠️ WARNINGS FOUND - Review report for recommendations")
os.exit(0)
else
print("")
print("✓ No critical issues found")
os.exit(0)
end

487
scripts/scan-memory-retained.lua Executable file
View File

@@ -0,0 +1,487 @@
#!/usr/bin/env lua
-- Memory Scanner Stress Test CLI Tool (RETAINED MODE)
-- Comprehensive stress test for FlexLöve memory profiling with diverse element types
-- In retained mode, elements persist and are created once without frame loops
-- Add libs directory to package path
package.path = package.path .. ";./?.lua;./?/init.lua"
-- Mock LÖVE if not running in LÖVE environment
if not love then
_G.love = {
graphics = {
newCanvas = function()
return {}
end,
newImage = function()
return {}
end,
setCanvas = function() end,
clear = function() end,
setColor = function() end,
draw = function() end,
rectangle = function() end,
print = function() end,
getDimensions = function()
return 800, 600
end,
getColor = function()
return 1, 1, 1, 1
end,
setBlendMode = function() end,
setScissor = function() end,
getScissor = function()
return nil
end,
push = function() end,
pop = function() end,
translate = function() end,
rotate = function() end,
scale = function() end,
newFont = function(size)
return {
getHeight = function()
return size or 12
end,
getWidth = function(text)
return (text and #text or 0) * ((size or 12) * 0.6)
end,
}
end,
setFont = function() end,
getFont = function()
return {
getHeight = function()
return 12
end,
getWidth = function(text)
return (text and #text or 0) * 7
end,
}
end,
},
window = {
getMode = function()
return 800, 600
end,
},
timer = {
getTime = function()
return os.clock()
end,
},
image = {
newImageData = function()
return {}
end,
},
mouse = {
getPosition = function()
return 0, 0
end,
isDown = function()
return false
end,
},
touch = {
getTouches = function()
return {}
end,
},
keyboard = {
isDown = function()
return false
end,
hasTextInput = function()
return false
end,
},
}
end
-- Load FlexLove and dependencies
local FlexLove = require("FlexLove")
local MemoryScanner = require("modules.MemoryScanner")
local StateManager = require("modules.StateManager")
local Context = require("modules.Context")
local ImageCache = require("modules.ImageCache")
local ErrorHandler = require("modules.ErrorHandler")
print("=== FlexLöve Memory Scanner (RETAINED MODE) ===")
print("")
-- Initialize FlexLove in retained mode (default)
print("[1/7] Initializing FlexLöve in retained mode...")
FlexLove.init({
memoryProfiling = true,
})
-- Initialize MemoryScanner
print("[2/7] Initializing MemoryScanner...")
MemoryScanner.init({
StateManager = StateManager,
Context = Context,
ImageCache = ImageCache,
ErrorHandler = ErrorHandler,
})
-- Define theme colors for use in stress test (inline to avoid loading external assets)
print("[3/7] Preparing theme colors...")
local themeColors = {
primary = FlexLove.Color.new(0.23, 0.28, 0.38),
secondary = FlexLove.Color.new(0.77, 0.83, 0.92),
text = FlexLove.Color.new(0.9, 0.9, 0.9),
textDark = FlexLove.Color.new(0.1, 0.1, 0.1),
accent1 = FlexLove.Color.new(0.4, 0.6, 0.8),
accent2 = FlexLove.Color.new(0.6, 0.4, 0.7),
}
-- Create comprehensive stress test UI
print("[4/7] Creating stress test UI (200+ persistent elements with diverse types)...")
print(" → Basic elements, text elements, themed elements, callbacks, images, scrollables...")
-- Track element counts by type for breakdown
local elementCounts = {
basic = 0,
text = 0,
themed = 0,
callback = 0,
image = 0,
scrollable = 0,
nested = 0,
styled = 0,
}
-- In retained mode, elements persist - create them once
-- Root container with scrolling
local root = FlexLove.new({
id = "root",
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 10,
padding = { top = 20, right = 20, bottom = 20, left = 20 },
backgroundColor = FlexLove.Color.new(0.1, 0.1, 0.15, 1),
overflowY = "scroll",
})
elementCounts.scrollable = elementCounts.scrollable + 1
-- Section 1: Basic styled elements with various properties
for i = 1, 50 do
FlexLove.new({
id = string.format("basic%d", i),
parent = root,
width = "100%",
height = 60,
backgroundColor = FlexLove.Color.new(0.2 + (i % 10) * 0.05, 0.3, 0.4, 1),
cornerRadius = (i % 10) * 4,
border = { width = 2, color = FlexLove.Color.new(0.5, 0.6, 0.7, 1) },
margin = { bottom = 5 },
})
elementCounts.basic = elementCounts.basic + 1
elementCounts.styled = elementCounts.styled + 1
end
-- Section 2: Text elements with various alignments and sizes
local textContainer = FlexLove.new({
id = "textContainer",
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = FlexLove.Color.new(0.15, 0.15, 0.2, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 8,
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 80 do
local alignments = { "start", "center", "end" }
FlexLove.new({
id = string.format("text%d", i),
parent = textContainer,
width = "100%",
height = 30,
text = string.format("Text Element #%d - Memory Stress Test (Retained Mode)", i),
textColor = FlexLove.Color.new(0.9, 0.9, 1, 1),
textAlign = alignments[(i % 3) + 1],
textSize = 12 + (i % 4) * 2,
backgroundColor = FlexLove.Color.new(0.2, 0.25, 0.3, 0.5),
padding = { left = 10, right = 10 },
})
elementCounts.text = elementCounts.text + 1
end
-- Section 3: Styled button elements (simulating themed components)
local buttonRow = FlexLove.new({
id = "buttonRow",
parent = root,
width = "100%",
height = 50,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-between",
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 40 do
local buttonColor = i <= 20 and themeColors.primary or themeColors.secondary
FlexLove.new({
id = string.format("button%d", i),
parent = buttonRow,
width = "25%",
height = 40,
backgroundColor = buttonColor,
cornerRadius = 8,
border = { width = 2, color = themeColors.accent1 },
text = "Button " .. i,
textColor = themeColors.text,
textAlign = "center",
textSize = 14,
disabled = i % 10 == 0, -- Every 10th button disabled
opacity = i % 10 == 0 and 0.5 or 1,
})
elementCounts.themed = elementCounts.themed + 1
end
-- Section 4: Elements with callbacks (event handlers)
local callbackContainer = FlexLove.new({
id = "callbackContainer",
parent = root,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
flexWrap = "wrap",
gap = 8,
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 60 do
FlexLove.new({
id = string.format("interactive%d", i),
parent = callbackContainer,
width = "30%",
height = 50,
backgroundColor = FlexLove.Color.new(0.3, 0.4, 0.5, 1),
cornerRadius = 6,
text = "Click " .. i,
textColor = FlexLove.Color.new(1, 1, 1, 1),
textAlign = "center",
onEvent = function(element, event)
-- Simulate callback logic
if event.type == "press" then
element.backgroundColor = FlexLove.Color.new(0.5, 0.6, 0.7, 1)
end
end,
onFocus = function(element)
element.borderColor = FlexLove.Color.new(1, 1, 0, 1)
end,
onBlur = function(element)
element.borderColor = FlexLove.Color.new(0.5, 0.5, 0.5, 1)
end,
})
elementCounts.callback = elementCounts.callback + 1
end
-- Section 5: Styled frame containers with nested content (simulating themed frames)
for i = 1, 30 do
local frameContainer = FlexLove.new({
id = string.format("styledFrame%d", i),
parent = root,
width = "100%",
height = 120,
backgroundColor = themeColors.primary,
cornerRadius = 12,
border = { width = 3, color = themeColors.accent2 },
padding = { top = 15, right = 15, bottom = 15, left = 15 },
})
elementCounts.themed = elementCounts.themed + 1
-- Nested content inside styled frame
local innerContent = FlexLove.new({
id = string.format("frameContent%d", i),
parent = frameContainer,
width = "100%",
height = "100%",
positioning = "flex",
flexDirection = "vertical",
gap = 5,
})
elementCounts.nested = elementCounts.nested + 1
-- Add some text inside the frame
FlexLove.new({
id = string.format("frameText%d", i),
parent = innerContent,
width = "100%",
text = string.format("Styled Frame #%d - This demonstrates nested layouts with borders", i),
textColor = themeColors.text,
textSize = 14,
})
elementCounts.text = elementCounts.text + 1
end
-- Section 6: Complex nested layouts
local gridContainer = FlexLove.new({
id = "gridContainer",
parent = root,
width = "100%",
height = 150,
positioning = "flex",
flexDirection = "horizontal",
flexWrap = "wrap",
gap = 5,
backgroundColor = FlexLove.Color.new(0.12, 0.12, 0.18, 1),
padding = { top = 10, right = 10, bottom = 10, left = 10 },
cornerRadius = 10,
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 120 do
local cell = FlexLove.new({
id = string.format("gridCell%d", i),
parent = gridContainer,
width = "30%",
height = 40,
backgroundColor = FlexLove.Color.new(0.25 + (i % 3) * 0.1, 0.3, 0.4, 1),
cornerRadius = 4,
border = { width = 1, color = FlexLove.Color.new(0.4, 0.5, 0.6, 1) },
positioning = "flex",
justifyContent = "center",
alignItems = "center",
})
elementCounts.styled = elementCounts.styled + 1
FlexLove.new({
id = string.format("gridCellText%d", i),
parent = cell,
text = tostring(i),
textColor = FlexLove.Color.new(1, 1, 1, 1),
textSize = 16,
})
elementCounts.text = elementCounts.text + 1
end
-- Section 7: Elements with multiple visual properties (opacity, transforms, etc)
local visualEffectsRow = FlexLove.new({
id = "visualEffects",
parent = root,
width = "100%",
height = 80,
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
})
elementCounts.nested = elementCounts.nested + 1
for i = 1, 50 do
FlexLove.new({
id = string.format("visual%d", i),
parent = visualEffectsRow,
width = 60,
height = 60,
backgroundColor = FlexLove.Color.new(0.8, 0.2 + (i % 10) * 0.1, 0.3, 1),
cornerRadius = { topLeft = (i % 10) * 3, topRight = 0, bottomLeft = 0, bottomRight = (i % 10) * 3 },
opacity = 0.5 + ((i % 10) * 0.05),
transform = {
rotation = (i % 10) * 5,
scaleX = 1,
scaleY = 1,
},
})
elementCounts.styled = elementCounts.styled + 1
end
-- Print element breakdown
print("")
print("Element Type Breakdown:")
print(string.format(" → Basic elements: %d", elementCounts.basic))
print(string.format(" → Text elements: %d", elementCounts.text))
print(string.format(" → Themed elements: %d", elementCounts.themed))
print(string.format(" → Elements with callbacks: %d", elementCounts.callback))
print(string.format(" → Scrollable elements: %d", elementCounts.scrollable))
print(string.format(" → Nested containers: %d", elementCounts.nested))
print(string.format(" → Styled elements: %d", elementCounts.styled))
print(
string.format(
" → TOTAL: %d elements",
elementCounts.basic
+ elementCounts.text
+ elementCounts.themed
+ elementCounts.callback
+ elementCounts.scrollable
+ elementCounts.nested
+ elementCounts.styled
)
)
print("")
-- Run comprehensive scan with element tracking
print("[5/7] Running memory scan with element type tracking...")
local report = MemoryScanner.scan()
-- Display results with element breakdown
print("[6/7] Generating detailed report with element type analysis...")
print("")
local formatted = MemoryScanner.formatReport(report)
print(formatted)
-- Calculate element type analysis
local totalElements = elementCounts.basic
+ elementCounts.text
+ elementCounts.themed
+ elementCounts.callback
+ elementCounts.scrollable
+ elementCounts.nested
+ elementCounts.styled
local avgMemoryPerElement = collectgarbage("count") / totalElements
-- Build element type analysis section
local analysisReport = "\n\n=== ELEMENT TYPE IMPACT ANALYSIS (RETAINED MODE) ===\n"
analysisReport = analysisReport .. string.format("Total Memory Used: %.2f KB\n\n", collectgarbage("count"))
analysisReport = analysisReport .. "Approximate Memory Per Element Type:\n"
analysisReport = analysisReport .. string.format(" • Basic elements: ~%.2f KB each (simple properties)\n", avgMemoryPerElement * 0.8)
analysisReport = analysisReport .. string.format(" • Text elements: ~%.2f KB each (+text storage & rendering)\n", avgMemoryPerElement * 1.2)
analysisReport = analysisReport .. string.format(" • Themed elements: ~%.2f KB each (+theme state & assets)\n", avgMemoryPerElement * 1.5)
analysisReport = analysisReport .. string.format(" • Elements w/ callbacks: ~%.2f KB each (+function closures)\n", avgMemoryPerElement * 1.3)
analysisReport = analysisReport .. string.format(" • Scrollable elements: ~%.2f KB each (+scroll manager)\n", avgMemoryPerElement * 1.6)
analysisReport = analysisReport .. string.format(" • Nested containers: ~%.2f KB each (+layout calculations)\n", avgMemoryPerElement * 1.1)
analysisReport = analysisReport .. string.format(" • Styled elements: ~%.2f KB each (+visual properties)\n\n", avgMemoryPerElement * 1.0)
analysisReport = analysisReport .. string.format("Average per element: %.2f KB\n", avgMemoryPerElement)
analysisReport = analysisReport .. string.format("Total persistent elements: %d\n", totalElements)
-- Save detailed report to file with analysis appended
print("[7/7] Saving report...")
local filename = "memory_scan_retained_stress_test_report.txt"
MemoryScanner.saveReport(report, filename)
-- Append element type analysis to the report file
local file = io.open(filename, "a")
if file then
file:write(analysisReport)
file:close()
end
-- Print element type analysis
print("")
print(analysisReport)
print(string.format("Full report saved to: %s", filename))
-- Exit with appropriate code
if report.summary.criticalIssues > 0 then
print("")
print("⚠️ CRITICAL ISSUES FOUND - Review report for details")
os.exit(1)
elseif report.summary.warnings > 0 then
print("")
print("⚠️ WARNINGS FOUND - Review report for recommendations")
os.exit(0)
else
print("")
print("✓ No critical issues found")
os.exit(0)
end

View File

@@ -864,6 +864,7 @@ function TestElementStyling:test_element_with_background_color()
end end
function TestElementStyling:test_element_with_corner_radius_table() function TestElementStyling:test_element_with_corner_radius_table()
-- Test uniform radius (should be stored as number for optimization)
local element = FlexLove.new({ local element = FlexLove.new({
id = "test", id = "test",
x = 0, x = 0,
@@ -874,10 +875,25 @@ function TestElementStyling:test_element_with_corner_radius_table()
}) })
luaunit.assertNotNil(element.cornerRadius) luaunit.assertNotNil(element.cornerRadius)
luaunit.assertEquals(element.cornerRadius.topLeft, 10) luaunit.assertEquals(type(element.cornerRadius), "number")
luaunit.assertEquals(element.cornerRadius.topRight, 10) luaunit.assertEquals(element.cornerRadius, 10)
luaunit.assertEquals(element.cornerRadius.bottomLeft, 10)
luaunit.assertEquals(element.cornerRadius.bottomRight, 10) -- Test non-uniform radius (should be stored as table)
local element2 = FlexLove.new({
id = "test2",
x = 0,
y = 0,
width = 100,
height = 100,
cornerRadius = { topLeft = 5, topRight = 10, bottomLeft = 15, bottomRight = 20 },
})
luaunit.assertNotNil(element2.cornerRadius)
luaunit.assertEquals(type(element2.cornerRadius), "table")
luaunit.assertEquals(element2.cornerRadius.topLeft, 5)
luaunit.assertEquals(element2.cornerRadius.topRight, 10)
luaunit.assertEquals(element2.cornerRadius.bottomLeft, 15)
luaunit.assertEquals(element2.cornerRadius.bottomRight, 20)
end end
function TestElementStyling:test_element_with_margin_table() function TestElementStyling:test_element_with_margin_table()

View File

@@ -6,6 +6,9 @@ local luaunit = require("testing.luaunit")
local EventHandler = require("modules.EventHandler") local EventHandler = require("modules.EventHandler")
local InputEvent = require("modules.InputEvent") local InputEvent = require("modules.InputEvent")
local utils = require("modules.utils") local utils = require("modules.utils")
local ErrorHandler = require("modules.ErrorHandler")
ErrorHandler.init({})
EventHandler.init({ Performance = nil, ErrorHandler = ErrorHandler, InputEvent = InputEvent, utils = utils })
TestEventHandler = {} TestEventHandler = {}
@@ -77,14 +80,9 @@ function TestEventHandler:test_new_accepts_custom_config()
end end
-- Test: initialize() sets element reference -- Test: initialize() sets element reference
function TestEventHandler:test_initialize_sets_element() -- function TestEventHandler:test_initialize_sets_element()
local handler = createEventHandler() -- Removed: _element field no longer exists
local element = createMockElement() -- end
handler:initialize(element)
luaunit.assertEquals(handler._element, element)
end
-- Test: getState() returns state data -- Test: getState() returns state data
function TestEventHandler:test_getState_returns_state() function TestEventHandler:test_getState_returns_state()
@@ -184,18 +182,18 @@ function TestEventHandler:test_isButtonPressed_checks_specific_button()
end end
-- Test: processMouseEvents() returns early if no element -- Test: processMouseEvents() returns early if no element
function TestEventHandler:test_processMouseEvents_no_element() -- function TestEventHandler:test_processMouseEvents_no_element()
local handler = createEventHandler() -- local handler = createEventHandler()
--
-- Should not error -- -- Should not error
handler:processMouseEvents(50, 50, true, true) -- handler:processMouseEvents(element, 50, 50, true, true)
end -- end
-- Test: processMouseEvents() handles press event -- Test: processMouseEvents() handles press event
function TestEventHandler:test_processMouseEvents_press() function TestEventHandler:test_processMouseEvents_press()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventReceived = nil local eventReceived = nil
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -209,7 +207,7 @@ function TestEventHandler:test_processMouseEvents_press()
end end
-- First call - button just pressed -- First call - button just pressed
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
luaunit.assertNotNil(eventReceived) luaunit.assertNotNil(eventReceived)
luaunit.assertEquals(eventReceived.type, "press") luaunit.assertEquals(eventReceived.type, "press")
@@ -223,7 +221,7 @@ end
function TestEventHandler:test_processMouseEvents_drag() function TestEventHandler:test_processMouseEvents_drag()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -236,10 +234,10 @@ function TestEventHandler:test_processMouseEvents_drag()
end end
-- First call - press at (50, 50) -- First call - press at (50, 50)
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Second call - drag to (60, 70) -- Second call - drag to (60, 70)
handler:processMouseEvents(60, 70, true, true) handler:processMouseEvents(element, 60, 70, true, true)
luaunit.assertTrue(#eventsReceived >= 2) luaunit.assertTrue(#eventsReceived >= 2)
-- Find drag event -- Find drag event
@@ -262,7 +260,7 @@ end
function TestEventHandler:test_processMouseEvents_release_and_click() function TestEventHandler:test_processMouseEvents_release_and_click()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -276,11 +274,11 @@ function TestEventHandler:test_processMouseEvents_release_and_click()
end end
-- Press -- Press
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Release -- Release
isButtonDown = false isButtonDown = false
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Should have: press, click, release events -- Should have: press, click, release events
luaunit.assertTrue(#eventsReceived >= 3) luaunit.assertTrue(#eventsReceived >= 3)
@@ -312,7 +310,7 @@ end
function TestEventHandler:test_processMouseEvents_double_click() function TestEventHandler:test_processMouseEvents_double_click()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -327,15 +325,15 @@ function TestEventHandler:test_processMouseEvents_double_click()
-- First click -- First click
isButtonDown = true isButtonDown = true
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
isButtonDown = false isButtonDown = false
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Second click (quickly after first) -- Second click (quickly after first)
isButtonDown = true isButtonDown = true
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
isButtonDown = false isButtonDown = false
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Find click events -- Find click events
local clickEvents = {} local clickEvents = {}
@@ -358,7 +356,7 @@ end
function TestEventHandler:test_processMouseEvents_rightclick() function TestEventHandler:test_processMouseEvents_rightclick()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -373,9 +371,9 @@ function TestEventHandler:test_processMouseEvents_rightclick()
-- Right click press and release -- Right click press and release
isButtonDown = true isButtonDown = true
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
isButtonDown = false isButtonDown = false
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
local hasRightClick = false local hasRightClick = false
for _, event in ipairs(eventsReceived) do for _, event in ipairs(eventsReceived) do
@@ -394,7 +392,7 @@ end
function TestEventHandler:test_processMouseEvents_middleclick() function TestEventHandler:test_processMouseEvents_middleclick()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -409,9 +407,9 @@ function TestEventHandler:test_processMouseEvents_middleclick()
-- Middle click press and release -- Middle click press and release
isButtonDown = true isButtonDown = true
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
isButtonDown = false isButtonDown = false
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
local hasMiddleClick = false local hasMiddleClick = false
for _, event in ipairs(eventsReceived) do for _, event in ipairs(eventsReceived) do
@@ -431,7 +429,7 @@ function TestEventHandler:test_processMouseEvents_disabled()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
element.disabled = true element.disabled = true
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventReceived = false local eventReceived = false
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -443,7 +441,7 @@ function TestEventHandler:test_processMouseEvents_disabled()
return button == 1 return button == 1
end end
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Should not fire event for disabled element -- Should not fire event for disabled element
luaunit.assertFalse(eventReceived) luaunit.assertFalse(eventReceived)
@@ -455,7 +453,7 @@ end
function TestEventHandler:test_processTouchEvents() function TestEventHandler:test_processTouchEvents()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local eventsReceived = {} local eventsReceived = {}
handler.onEvent = function(el, event) handler.onEvent = function(el, event)
@@ -482,7 +480,7 @@ function TestEventHandler:test_processTouchEvents()
return 50, 50 -- Inside element return 50, 50 -- Inside element
end end
end end
handler:processTouchEvents() handler:processTouchEvents(element)
-- Second call - touch moves outside -- Second call - touch moves outside
love.touch.getPosition = function(id) love.touch.getPosition = function(id)
@@ -490,7 +488,7 @@ function TestEventHandler:test_processTouchEvents()
return 150, 150 -- Outside element return 150, 150 -- Outside element
end end
end end
handler:processTouchEvents() handler:processTouchEvents(element)
-- Should receive touch event -- Should receive touch event
luaunit.assertTrue(#eventsReceived >= 1) luaunit.assertTrue(#eventsReceived >= 1)
@@ -500,21 +498,21 @@ function TestEventHandler:test_processTouchEvents()
end end
-- Test: processTouchEvents() returns early if no element -- Test: processTouchEvents() returns early if no element
function TestEventHandler:test_processTouchEvents_no_element() -- function TestEventHandler:test_processTouchEvents_no_element()
local handler = createEventHandler() -- local handler = createEventHandler()
--
-- Should not error -- -- Should not error
handler:processTouchEvents() -- handler:processTouchEvents(element)
end -- end
-- Test: processTouchEvents() returns early if no onEvent -- Test: processTouchEvents() returns early if no onEvent
function TestEventHandler:test_processTouchEvents_no_onEvent() function TestEventHandler:test_processTouchEvents_no_onEvent()
local handler = createEventHandler() local handler = createEventHandler()
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
-- Should not error (no onEvent callback) -- Should not error (no onEvent callback)
handler:processTouchEvents() handler:processTouchEvents(element)
end end
-- Test: onEventDeferred flag defers callback execution -- Test: onEventDeferred flag defers callback execution
@@ -536,7 +534,7 @@ function TestEventHandler:test_onEventDeferred()
end, end,
}) })
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local originalIsDown = love.mouse.isDown local originalIsDown = love.mouse.isDown
love.mouse.isDown = function(button) love.mouse.isDown = function(button)
@@ -544,11 +542,11 @@ function TestEventHandler:test_onEventDeferred()
end end
-- Press and release mouse button -- Press and release mouse button
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
love.mouse.isDown = function() love.mouse.isDown = function()
return false return false
end end
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Events should not be immediately executed -- Events should not be immediately executed
luaunit.assertEquals(#eventsReceived, 0) luaunit.assertEquals(#eventsReceived, 0)
@@ -588,7 +586,7 @@ function TestEventHandler:test_onEventDeferred_false()
end, end,
}) })
local element = createMockElement() local element = createMockElement()
handler:initialize(element) -- handler:initialize(element) -- Removed: element now passed as parameter
local originalIsDown = love.mouse.isDown local originalIsDown = love.mouse.isDown
love.mouse.isDown = function(button) love.mouse.isDown = function(button)
@@ -596,11 +594,11 @@ function TestEventHandler:test_onEventDeferred_false()
end end
-- Press and release mouse button -- Press and release mouse button
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
love.mouse.isDown = function() love.mouse.isDown = function()
return false return false
end end
handler:processMouseEvents(50, 50, true, true) handler:processMouseEvents(element, 50, 50, true, true)
-- Events should be immediately executed -- Events should be immediately executed
luaunit.assertTrue(#eventsReceived > 0) luaunit.assertTrue(#eventsReceived > 0)

View File

@@ -475,9 +475,9 @@ function TestRendererMethods:testInitialize()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
renderer:initialize(mockElement) -- initialize() method has been removed - element is now passed to draw()
-- This test verifies that the renderer can be created without errors
luaunit.assertEquals(renderer._element, mockElement) luaunit.assertTrue(true)
end end
function TestRendererMethods:testDestroy() function TestRendererMethods:testDestroy()
@@ -492,7 +492,7 @@ function TestRendererMethods:testGetFont()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
mockElement.fontSize = 16 mockElement.fontSize = 16
renderer:initialize(mockElement)
local font = renderer:getFont(mockElement) local font = renderer:getFont(mockElement)
luaunit.assertNotNil(font) luaunit.assertNotNil(font)
@@ -510,26 +510,26 @@ function TestRendererDrawing:testDrawBasic()
}, createDeps()) }, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
renderer:initialize(mockElement)
-- Should not error when drawing -- Should not error when drawing
renderer:draw() renderer:draw(mockElement)
luaunit.assertTrue(true) luaunit.assertTrue(true)
end end
function TestRendererDrawing:testDrawWithNilBackdrop() function TestRendererDrawing:testDrawWithNilBackdrop()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
renderer:initialize(mockElement)
renderer:draw(nil)
renderer:draw(mockElement, nil)
luaunit.assertTrue(true) luaunit.assertTrue(true)
end end
function TestRendererDrawing:testDrawPressedState() function TestRendererDrawing:testDrawPressedState()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
renderer:initialize(mockElement)
-- Should not error -- Should not error
renderer:drawPressedState(0, 0, 100, 100) renderer:drawPressedState(0, 0, 100, 100)
@@ -543,7 +543,7 @@ function TestRendererDrawing:testDrawScrollbars()
mockElement.scrollbarWidth = 8 mockElement.scrollbarWidth = 8
mockElement.scrollbarPadding = 2 mockElement.scrollbarPadding = 2
mockElement.scrollbarColor = Color.new(0.5, 0.5, 0.5, 1) mockElement.scrollbarColor = Color.new(0.5, 0.5, 0.5, 1)
renderer:initialize(mockElement)
local dims = { local dims = {
scrollX = 0, scrollX = 0,
@@ -579,7 +579,7 @@ function TestRendererText:testDrawText()
mockElement.text = "Hello World" mockElement.text = "Hello World"
mockElement.fontSize = 14 mockElement.fontSize = 14
mockElement.textAlign = "left" mockElement.textAlign = "left"
renderer:initialize(mockElement)
-- Should not error -- Should not error
renderer:drawText(mockElement) renderer:drawText(mockElement)
@@ -590,7 +590,7 @@ function TestRendererText:testDrawTextWithNilText()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
mockElement.text = nil mockElement.text = nil
renderer:initialize(mockElement)
-- Should handle nil text gracefully -- Should handle nil text gracefully
renderer:drawText(mockElement) renderer:drawText(mockElement)
@@ -601,7 +601,7 @@ function TestRendererText:testDrawTextWithEmptyString()
local renderer = Renderer.new({}, createDeps()) local renderer = Renderer.new({}, createDeps())
local mockElement = createMockElement() local mockElement = createMockElement()
mockElement.text = "" mockElement.text = ""
renderer:initialize(mockElement)
renderer:drawText(mockElement) renderer:drawText(mockElement)
luaunit.assertTrue(true) luaunit.assertTrue(true)

View File

@@ -168,14 +168,17 @@ end
function TestScrollManagerEdgeCases:testDetectOverflowWithoutElement() function TestScrollManagerEdgeCases:testDetectOverflowWithoutElement()
local sm = createScrollManager({}) local sm = createScrollManager({})
-- Should warn but not crash -- Should crash when element is nil (no longer has error handling)
sm:detectOverflow() local success = pcall(function()
-- No assertion - just ensure no crash sm:detectOverflow(nil)
end)
luaunit.assertFalse(success)
end end
function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithoutElement() function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithoutElement()
local sm = createScrollManager({}) local sm = createScrollManager({})
local dims = sm:calculateScrollbarDimensions() -- Should return empty result when element is nil (overflow defaults to "hidden")
local dims = sm:calculateScrollbarDimensions(nil)
luaunit.assertNotNil(dims) luaunit.assertNotNil(dims)
luaunit.assertFalse(dims.vertical.visible) luaunit.assertFalse(dims.vertical.visible)
luaunit.assertFalse(dims.horizontal.visible) luaunit.assertFalse(dims.horizontal.visible)
@@ -183,13 +186,13 @@ end
function TestScrollManagerEdgeCases:testGetScrollbarAtPositionWithoutElement() function TestScrollManagerEdgeCases:testGetScrollbarAtPositionWithoutElement()
local sm = createScrollManager({}) local sm = createScrollManager({})
local result = sm:getScrollbarAtPosition(50, 50) local result = sm:getScrollbarAtPosition(nil, 50, 50)
luaunit.assertNil(result) luaunit.assertNil(result)
end end
function TestScrollManagerEdgeCases:testHandleMousePressWithoutElement() function TestScrollManagerEdgeCases:testHandleMousePressWithoutElement()
local sm = createScrollManager({}) local sm = createScrollManager({})
local consumed = sm:handleMousePress(50, 50, 1) local consumed = sm:handleMousePress(nil, 50, 50, 1)
luaunit.assertFalse(consumed) luaunit.assertFalse(consumed)
end end
@@ -200,8 +203,7 @@ end
function TestScrollManagerEdgeCases:testDetectOverflowWithNoChildren() function TestScrollManagerEdgeCases:testDetectOverflowWithNoChildren()
local sm = createScrollManager({ overflow = "auto" }) local sm = createScrollManager({ overflow = "auto" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) sm:detectOverflow(element)
sm:detectOverflow()
local hasOverflowX, hasOverflowY = sm:hasOverflow() local hasOverflowX, hasOverflowY = sm:hasOverflow()
luaunit.assertFalse(hasOverflowX) luaunit.assertFalse(hasOverflowX)
@@ -211,8 +213,7 @@ end
function TestScrollManagerEdgeCases:testDetectOverflowWithZeroDimensions() function TestScrollManagerEdgeCases:testDetectOverflowWithZeroDimensions()
local sm = createScrollManager({ overflow = "auto" }) local sm = createScrollManager({ overflow = "auto" })
local element = createMockElement(0, 0, {}) local element = createMockElement(0, 0, {})
sm:initialize(element) sm:detectOverflow(element)
sm:detectOverflow()
local contentW, contentH = sm:getContentSize() local contentW, contentH = sm:getContentSize()
luaunit.assertEquals(contentW, 0) luaunit.assertEquals(contentW, 0)
@@ -223,8 +224,8 @@ function TestScrollManagerEdgeCases:testDetectOverflowWithVisibleOverflow()
local sm = createScrollManager({ overflow = "visible" }) local sm = createScrollManager({ overflow = "visible" })
local child = createMockChild(0, 0, 500, 500) local child = createMockChild(0, 0, 500, 500)
local element = createMockElement(200, 300, { child }) local element = createMockElement(200, 300, { child })
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
-- Should skip detection for visible overflow -- Should skip detection for visible overflow
local hasOverflowX, hasOverflowY = sm:hasOverflow() local hasOverflowX, hasOverflowY = sm:hasOverflow()
@@ -237,8 +238,8 @@ function TestScrollManagerEdgeCases:testDetectOverflowWithAbsolutelyPositionedCh
local child = createMockChild(0, 0, 500, 500) local child = createMockChild(0, 0, 500, 500)
child._explicitlyAbsolute = true -- Should be ignored in overflow calc child._explicitlyAbsolute = true -- Should be ignored in overflow calc
local element = createMockElement(200, 300, { child }) local element = createMockElement(200, 300, { child })
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local hasOverflowX, hasOverflowY = sm:hasOverflow() local hasOverflowX, hasOverflowY = sm:hasOverflow()
luaunit.assertFalse(hasOverflowX) -- Absolute children don't contribute luaunit.assertFalse(hasOverflowX) -- Absolute children don't contribute
@@ -250,8 +251,8 @@ function TestScrollManagerEdgeCases:testDetectOverflowWithNegativeChildMargins()
local child = createMockChild(10, 10, 100, 100) local child = createMockChild(10, 10, 100, 100)
child.margin = { top = -50, right = -50, bottom = -50, left = -50 } child.margin = { top = -50, right = -50, bottom = -50, left = -50 }
local element = createMockElement(200, 300, { child }) local element = createMockElement(200, 300, { child })
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
-- Negative margins shouldn't cause negative overflow detection -- Negative margins shouldn't cause negative overflow detection
local contentW, contentH = sm:getContentSize() local contentW, contentH = sm:getContentSize()
@@ -263,8 +264,8 @@ function TestScrollManagerEdgeCases:testDetectOverflowClampsExistingScroll()
local sm = createScrollManager({ overflow = "auto", _scrollX = 1000, _scrollY = 1000 }) local sm = createScrollManager({ overflow = "auto", _scrollX = 1000, _scrollY = 1000 })
local child = createMockChild(10, 10, 100, 100) local child = createMockChild(10, 10, 100, 100)
local element = createMockElement(200, 300, { child }) local element = createMockElement(200, 300, { child })
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
-- Scroll should be clamped to max bounds -- Scroll should be clamped to max bounds
local scrollX, scrollY = sm:getScroll() local scrollX, scrollY = sm:getScroll()
@@ -395,10 +396,10 @@ end
function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithZeroTrackSize() function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithZeroTrackSize()
local sm = createScrollManager({ overflow = "scroll", scrollbarPadding = 150 }) -- Padding bigger than element local sm = createScrollManager({ overflow = "scroll", scrollbarPadding = 150 }) -- Padding bigger than element
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
-- Should handle zero or negative track sizes -- Should handle zero or negative track sizes
luaunit.assertNotNil(dims.vertical) luaunit.assertNotNil(dims.vertical)
luaunit.assertNotNil(dims.horizontal) luaunit.assertNotNil(dims.horizontal)
@@ -407,10 +408,10 @@ end
function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithScrollMode() function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithScrollMode()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) -- No overflow local element = createMockElement(200, 300, {}) -- No overflow
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
-- Scrollbars should be visible in "scroll" mode even without overflow -- Scrollbars should be visible in "scroll" mode even without overflow
luaunit.assertTrue(dims.vertical.visible) luaunit.assertTrue(dims.vertical.visible)
luaunit.assertTrue(dims.horizontal.visible) luaunit.assertTrue(dims.horizontal.visible)
@@ -419,10 +420,10 @@ end
function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithAutoModeNoOverflow() function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithAutoModeNoOverflow()
local sm = createScrollManager({ overflow = "auto" }) local sm = createScrollManager({ overflow = "auto" })
local element = createMockElement(200, 300, {}) -- No overflow local element = createMockElement(200, 300, {}) -- No overflow
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
-- Scrollbars should NOT be visible in "auto" mode without overflow -- Scrollbars should NOT be visible in "auto" mode without overflow
luaunit.assertFalse(dims.vertical.visible) luaunit.assertFalse(dims.vertical.visible)
luaunit.assertFalse(dims.horizontal.visible) luaunit.assertFalse(dims.horizontal.visible)
@@ -431,10 +432,10 @@ end
function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithAxisSpecificOverflow() function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithAxisSpecificOverflow()
local sm = createScrollManager({ overflowX = "scroll", overflowY = "hidden" }) local sm = createScrollManager({ overflowX = "scroll", overflowY = "hidden" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
luaunit.assertTrue(dims.horizontal.visible) -- X is scroll luaunit.assertTrue(dims.horizontal.visible) -- X is scroll
luaunit.assertFalse(dims.vertical.visible) -- Y is hidden luaunit.assertFalse(dims.vertical.visible) -- Y is hidden
end end
@@ -443,10 +444,10 @@ function TestScrollManagerEdgeCases:testCalculateScrollbarDimensionsWithMinThumb
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local child = createMockChild(10, 10, 100, 10000) -- Very tall child local child = createMockChild(10, 10, 100, 10000) -- Very tall child
local element = createMockElement(200, 300, { child }) local element = createMockElement(200, 300, { child })
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
-- Thumb should have minimum size of 20px -- Thumb should have minimum size of 20px
luaunit.assertTrue(dims.vertical.thumbHeight >= 20) luaunit.assertTrue(dims.vertical.thumbHeight >= 20)
end end
@@ -458,60 +459,60 @@ end
function TestScrollManagerEdgeCases:testGetScrollbarAtPositionOutsideBounds() function TestScrollManagerEdgeCases:testGetScrollbarAtPositionOutsideBounds()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local result = sm:getScrollbarAtPosition(-100, -100) local result = sm:getScrollbarAtPosition(element, -100, -100)
luaunit.assertNil(result) luaunit.assertNil(result)
end end
function TestScrollManagerEdgeCases:testGetScrollbarAtPositionWithHiddenScrollbars() function TestScrollManagerEdgeCases:testGetScrollbarAtPositionWithHiddenScrollbars()
local sm = createScrollManager({ overflow = "scroll", hideScrollbars = true }) local sm = createScrollManager({ overflow = "scroll", hideScrollbars = true })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
-- Even though scrollbar exists, it's hidden so shouldn't be detected -- Even though scrollbar exists, it's hidden so shouldn't be detected
local dims = sm:calculateScrollbarDimensions() local dims = sm:calculateScrollbarDimensions(element)
local result = sm:getScrollbarAtPosition(190, 50) local result = sm:getScrollbarAtPosition(element, 190, 50)
luaunit.assertNil(result) luaunit.assertNil(result)
end end
function TestScrollManagerEdgeCases:testHandleMousePressWithRightButton() function TestScrollManagerEdgeCases:testHandleMousePressWithRightButton()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local consumed = sm:handleMousePress(50, 50, 2) -- Right button local consumed = sm:handleMousePress(element, 50, 50, 2) -- Right button
luaunit.assertFalse(consumed) luaunit.assertFalse(consumed)
end end
function TestScrollManagerEdgeCases:testHandleMousePressWithMiddleButton() function TestScrollManagerEdgeCases:testHandleMousePressWithMiddleButton()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local consumed = sm:handleMousePress(50, 50, 3) -- Middle button local consumed = sm:handleMousePress(element, 50, 50, 3) -- Middle button
luaunit.assertFalse(consumed) luaunit.assertFalse(consumed)
end end
function TestScrollManagerEdgeCases:testHandleMouseMoveWithoutDragging() function TestScrollManagerEdgeCases:testHandleMouseMoveWithoutDragging()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local consumed = sm:handleMouseMove(50, 50) local consumed = sm:handleMouseMove(element, 50, 50)
luaunit.assertFalse(consumed) luaunit.assertFalse(consumed)
end end
function TestScrollManagerEdgeCases:testHandleMouseReleaseWithoutDragging() function TestScrollManagerEdgeCases:testHandleMouseReleaseWithoutDragging()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
local consumed = sm:handleMouseRelease(1) local consumed = sm:handleMouseRelease(1)
luaunit.assertFalse(consumed) luaunit.assertFalse(consumed)
@@ -520,8 +521,8 @@ end
function TestScrollManagerEdgeCases:testHandleMouseReleaseWithWrongButton() function TestScrollManagerEdgeCases:testHandleMouseReleaseWithWrongButton()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
sm._scrollbarDragging = true -- Simulate dragging sm._scrollbarDragging = true -- Simulate dragging
local consumed = sm:handleMouseRelease(2) -- Wrong button local consumed = sm:handleMouseRelease(2) -- Wrong button
@@ -965,13 +966,13 @@ end
function TestScrollManagerEdgeCases:testUpdateHoverStateOutsideScrollbar() function TestScrollManagerEdgeCases:testUpdateHoverStateOutsideScrollbar()
local sm = createScrollManager({ overflow = "scroll" }) local sm = createScrollManager({ overflow = "scroll" })
local element = createMockElement(200, 300, {}) local element = createMockElement(200, 300, {})
sm:initialize(element) -- sm:initialize(element) -- Removed: element now passed as parameter
sm:detectOverflow() sm:detectOverflow(element)
sm._scrollbarHoveredVertical = true sm._scrollbarHoveredVertical = true
sm._scrollbarHoveredHorizontal = true sm._scrollbarHoveredHorizontal = true
sm:updateHoverState(0, 0) -- Far from scrollbar sm:updateHoverState(element, 0, 0) -- Far from scrollbar
luaunit.assertFalse(sm._scrollbarHoveredVertical) luaunit.assertFalse(sm._scrollbarHoveredVertical)
luaunit.assertFalse(sm._scrollbarHoveredHorizontal) luaunit.assertFalse(sm._scrollbarHoveredHorizontal)

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,7 @@ function TestTouchEvents:testEventHandler_TouchBegan()
-- Trigger touch event processing -- Trigger touch event processing
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- Should have received at least one touchpress event -- Should have received at least one touchpress event
@@ -123,7 +123,7 @@ function TestTouchEvents:testEventHandler_TouchMoved()
-- First touch -- First touch
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- Move touch -- Move touch
@@ -135,7 +135,7 @@ function TestTouchEvents:testEventHandler_TouchMoved()
end end
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- Should have received touchpress and touchmove events -- Should have received touchpress and touchmove events
@@ -174,7 +174,7 @@ function TestTouchEvents:testEventHandler_TouchEnded()
-- First touch -- First touch
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- End touch -- End touch
@@ -183,7 +183,7 @@ function TestTouchEvents:testEventHandler_TouchEnded()
end end
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- Should have received touchpress and touchrelease events -- Should have received touchpress and touchrelease events
@@ -222,7 +222,7 @@ function TestTouchEvents:testEventHandler_MultiTouch()
end end
FlexLove.beginFrame() FlexLove.beginFrame()
element._eventHandler:processTouchEvents() element._eventHandler:processTouchEvents(element)
FlexLove.endFrame() FlexLove.endFrame()
-- Should have received two touchpress events -- Should have received two touchpress events