memory tooling, state handling changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
668
modules/MemoryScanner.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user