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

View File

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

View File

@@ -14,7 +14,6 @@
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _element Element?
---@field _scrollbarPressHandled boolean
---@field _InputEvent table
---@field _utils table
@@ -60,19 +59,11 @@ function EventHandler.new(config)
self._hovered = config._hovered or false
self._element = nil
self._scrollbarPressHandled = false
return self
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)
---@return table State data
function EventHandler:getState()
@@ -116,26 +107,18 @@ function EventHandler:setState(state)
end
--- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse")
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)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
@@ -189,18 +172,18 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
if not wasPressed then
-- Just pressed - fire press event (only if hovering)
if isHovering then
self:_handleMousePress(mx, my, button)
self:_handleMousePress(element, mx, my, button)
end
else
-- Button is still pressed - check for drag
self:_handleMouseDrag(mx, my, button, isHovering)
self:_handleMouseDrag(element, mx, my, button, isHovering)
end
elseif wasPressed then
-- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element)
if isHovering and isActiveElement then
self:_handleMouseRelease(mx, my, button)
self:_handleMouseRelease(element, mx, my, button)
else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false
@@ -230,15 +213,11 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
end
--- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(mx, my, button)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleMousePress(element, mx, my, button)
-- Check if press is on scrollbar first (skip if already handled)
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
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
end
@@ -278,16 +257,12 @@ function EventHandler:_handleMousePress(mx, my, button)
end
--- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
@@ -327,12 +302,7 @@ end
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
function EventHandler:_handleMouseRelease(mx, my, button)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
@@ -412,21 +382,16 @@ function EventHandler:_handleMouseRelease(mx, my, button)
end
--- Process touch events in the update cycle
function EventHandler:processTouchEvents()
---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch")
end
if not self._element then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
return
end
local element = self._element
-- Get all active touches from LÖVE
local loveTouches = love.touch.getTouches()
local activeTouchIds = {}
-- Check if element can process events
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled
@@ -462,19 +427,19 @@ function EventHandler:processTouchEvents()
if isInside then
if not self._touches[touchId] then
-- New touch began
self:_handleTouchBegan(touchId, tx, ty, pressure)
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
else
-- Touch moved
self:_handleTouchMoved(touchId, tx, ty, pressure)
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(touchId, tx, ty, pressure)
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(touchId, tx, ty, pressure)
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end
end
end
@@ -485,7 +450,7 @@ function EventHandler:processTouchEvents()
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(touchId, lastPos.x, lastPos.y, 1.0)
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
@@ -500,16 +465,12 @@ function EventHandler:processTouchEvents()
end
--- 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 y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
-- Create touch state
self._touches[touchId] = {
@@ -536,16 +497,12 @@ function EventHandler:_handleTouchBegan(touchId, x, y, pressure)
end
--- 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 y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
@@ -587,16 +544,12 @@ function EventHandler:_handleTouchMoved(touchId, x, y, pressure)
end
--- 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 y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(touchId, x, y, pressure)
if not self._element then
return
end
local element = self._element
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
@@ -689,4 +642,13 @@ function EventHandler:_invokeCallback(element, event)
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

View File

@@ -1018,4 +1018,13 @@ function LayoutEngine:_trackLayoutRecalculation()
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

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._TextAlign = deps.utils.enums.TextAlign
-- Store reference to parent element (will be set via initialize)
self._element = nil
-- Visual properties
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
@@ -123,11 +120,7 @@ function Renderer.new(config, deps)
return self
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
---@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
-- Apply cornerRadius clipping if set
local hasCornerRadius = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 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.bottomLeft > 0
or self.cornerRadius.bottomRight > 0
end
end
if hasCornerRadius then
-- 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
-- Check if it's a stencil buffer error
if err and err:match("stencil") then
Renderer._ErrorHandler:warn("Renderer", "IMG_001", "Cannot apply corner radius to image: stencil buffer not available", {
imagePath = self.imagePath or "unknown",
cornerRadius = string.format(
local cornerRadiusStr
if type(self.cornerRadius) == "number" then
cornerRadiusStr = tostring(self.cornerRadius)
else
cornerRadiusStr = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
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),
}, "Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images")
-- Continue without corner radius
@@ -330,6 +336,19 @@ end
---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height
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)
love.graphics.setColor(borderColorWithOpacity:toRGBA())
@@ -357,12 +376,20 @@ function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
end
--- Main draw method - renders all visual layers
---@param element Element The parent Element instance
---@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
local elementId
if Renderer._Performance and Renderer._Performance.enabled and self._element then
elementId = self._element.id or "unnamed"
if Renderer._Performance and Renderer._Performance.enabled and element then
elementId = element.id or "unnamed"
Renderer._Performance:startTimer("render_" .. elementId)
Renderer._Performance:incrementCounter("draw_calls", 1)
end
@@ -375,16 +402,6 @@ function Renderer:draw(backdropCanvas)
return
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
local drawBackgroundColor = self.backgroundColor
if element.animation then
@@ -641,8 +658,8 @@ end
function Renderer:drawText(element)
-- Update text layout if dirty (for multiline auto-grow)
if element._textEditor then
element._textEditor:_updateTextIfDirty()
element._textEditor:updateAutoGrowHeight()
element._textEditor:_updateTextIfDirty(element)
element._textEditor:updateAutoGrowHeight(element)
end
-- For editable elements, use TextEditor buffer; for non-editable, use text
@@ -762,7 +779,7 @@ function Renderer:drawText(element)
love.graphics.setColor(cursorWithOpacity:toRGBA())
-- Calculate cursor position using TextEditor method
local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition()
local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition(element)
local cursorX = contentX + cursorRelX
local cursorY = contentY + cursorRelY
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)
-- 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
if not element.multiline then
@@ -940,9 +957,16 @@ end
--- Cleanup renderer resources
function Renderer:destroy()
self._element = nil
self._loadedImage = nil
self._blurInstance = nil
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

View File

@@ -5,7 +5,7 @@ local RoundedRect = {}
---@param y number
---@param width 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)
---@return table -- Array of vertices for love.graphics.polygon
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
@@ -27,6 +27,16 @@ function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
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 r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
@@ -53,8 +63,29 @@ end
---@param y number
---@param width 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)
-- 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
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 width 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
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
return function()

View File

@@ -1,4 +1,3 @@
---@class ScrollManager
---@field overflow string -- "visible"|"hidden"|"auto"|"scroll"
---@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 bounceStiffness number -- Bounce spring constant (0.1-0.3)
---@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 _overflowY boolean -- True if content overflows vertically
---@field _contentWidth number -- Total content width (including overflow)
@@ -117,29 +115,12 @@ function ScrollManager.new(config, deps)
self._lastTouchX = 0
self._lastTouchY = 0
-- Element reference (set via initialize)
self._element = nil
return self
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
function ScrollManager:detectOverflow()
if not self._element then
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
---@param element Element The parent Element instance
function ScrollManager:detectOverflow(element)
-- Reset overflow state
self._overflowX = false
self._overflowY = false
@@ -259,19 +240,9 @@ function ScrollManager:getContentSize()
end
--- Calculate scrollbar dimensions and positions
---@param element Element The parent Element instance
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
function ScrollManager:calculateScrollbarDimensions()
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
function ScrollManager:calculateScrollbarDimensions(element)
local result = {
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
@@ -356,18 +327,11 @@ function ScrollManager:calculateScrollbarDimensions()
end
--- Get scrollbar at mouse position
---@param element Element The parent Element instance
---@param mouseX number
---@param mouseY number
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
function ScrollManager:getScrollbarAtPosition(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
function ScrollManager:getScrollbarAtPosition(element, mouseX, mouseY)
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
@@ -375,7 +339,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
return nil
end
local dims = self:calculateScrollbarDimensions()
local dims = self:calculateScrollbarDimensions(element)
local x, y = element.x, element.y
local w, h = element.width, element.height
@@ -427,23 +391,17 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
end
--- Handle scrollbar mouse press
---@param element Element The parent Element instance
---@param mouseX number
---@param mouseY number
---@param button number
---@return boolean -- True if event was consumed
function ScrollManager:handleMousePress(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
function ScrollManager:handleMousePress(element, mouseX, mouseY, button)
if button ~= 1 then
return false
end -- Only left click
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY)
local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY)
if not scrollbar then
return false
end
@@ -452,8 +410,7 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
-- Start dragging thumb
self._scrollbarDragging = true
self._hoveredScrollbar = scrollbar.component
local dims = self:calculateScrollbarDimensions()
local element = self._element
local dims = self:calculateScrollbarDimensions(element)
if scrollbar.component == "vertical" then
local contentY = element.y + element.padding.top
@@ -470,7 +427,7 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
return true -- Event consumed
elseif scrollbar.region == "track" then
-- Click on track - jump to position
self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component)
self:_scrollToTrackPosition(element, mouseX, mouseY, scrollbar.component)
return true
end
@@ -478,20 +435,16 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
end
--- Handle scrollbar drag
---@param element Element The parent Element instance
---@param mouseX number
---@param mouseY number
---@return boolean -- True if event was consumed
function ScrollManager:handleMouseMove(mouseX, mouseY)
if not self._element then
return false
end
function ScrollManager:handleMouseMove(element, mouseX, mouseY)
if not self._scrollbarDragging then
return false
end
local dims = self:calculateScrollbarDimensions()
local element = self._element
local dims = self:calculateScrollbarDimensions(element)
if self._hoveredScrollbar == "vertical" then
local contentY = element.y + element.padding.top
@@ -547,16 +500,12 @@ function ScrollManager:handleMouseRelease(button)
end
--- Scroll to track click position (internal helper)
---@param element Element The parent Element instance
---@param mouseX number
---@param mouseY number
---@param component string -- "vertical" or "horizontal"
function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component)
if not self._element then
return
end
local dims = self:calculateScrollbarDimensions()
local element = self._element
function ScrollManager:_scrollToTrackPosition(element, mouseX, mouseY, component)
local dims = self:calculateScrollbarDimensions(element)
if component == "vertical" then
local contentY = element.y + element.padding.top
@@ -628,10 +577,11 @@ function ScrollManager:handleWheel(x, y)
end
--- Update scrollbar hover state based on mouse position
---@param element Element The parent Element instance
---@param mouseX number
---@param mouseY number
function ScrollManager:updateHoverState(mouseX, mouseY)
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY)
function ScrollManager:updateHoverState(element, mouseX, mouseY)
local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY)
if scrollbar then
if scrollbar.component == "vertical" then
@@ -801,7 +751,7 @@ function ScrollManager:handleTouchRelease()
-- Start momentum scrolling if enabled and velocity is significant
if self.momentumScrollEnabled then
local velocityThreshold = 50 -- pixels per second
local totalVelocity = math.sqrt(self._scrollVelocityX^2 + self._scrollVelocityY^2)
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
if totalVelocity > velocityThreshold then
self._momentumScrolling = true
@@ -845,7 +795,7 @@ function ScrollManager:update(dt)
self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction
-- Stop momentum when velocity is very low
local totalVelocity = math.sqrt(self._scrollVelocityX^2 + self._scrollVelocityY^2)
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
if totalVelocity < 1 then
self._momentumScrolling = false
self._scrollVelocityX = 0
@@ -919,4 +869,14 @@ function ScrollManager:isMomentumScrolling()
return self._momentumScrolling
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

View File

@@ -18,10 +18,92 @@ local callSiteCounters = {}
-- Configuration
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
}
-- 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
-- ====================
@@ -190,121 +272,28 @@ function StateManager.getState(id, defaultState)
end
ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", {
parameter = "id",
value = "nil"
value = "nil",
}, "Provide a valid non-nil ID string to getState()")
end
-- Create state if it doesn't exist
if not stateStore[id] then
-- Merge default state with standard structure
-- Start with empty state (sparse storage)
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
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 0,
}
else
-- Update metadata
local meta = stateMetadata[id]
meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1
end
-- Update metadata
local meta = stateMetadata[id]
meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1
return stateStore[id]
end
@@ -319,11 +308,19 @@ function StateManager.setState(id, state)
end
ErrorHandler.error("StateManager", "SYS_001", "Invalid state ID", {
parameter = "id",
value = "nil"
value = "nil",
}, "Provide a valid non-nil ID string to setState()")
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
if not stateMetadata[id] then
@@ -414,6 +411,15 @@ function StateManager.cleanup()
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
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._themeState = "normal"
self._element = nil
return self
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
---@param isHovered boolean Whether element is hovered
---@param isPressed boolean Whether element is pressed
@@ -968,6 +961,13 @@ function ThemeManager:setTheme(themeName, componentName)
self.themeComponent = componentName
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
Theme.Manager = ThemeManager

View File

@@ -505,6 +505,7 @@ end
--- @param options table? Sanitization options
--- @return string Sanitized text
local function sanitizeText(text, options)
local utf8 = require("utf8")
-- Handle nil or non-string inputs
if text == nil then
return ""
@@ -542,11 +543,16 @@ local function sanitizeText(text, options)
text = text:match("^%s*(.-)%s*$") or ""
end
-- Limit string length
if #text > maxLength then
text = text:sub(1, maxLength)
-- Limit string length (use UTF-8 character count, not byte count)
local charCount = utf8.len(text)
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
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