immediate mode state management cleanup
This commit is contained in:
88
FlexLove.lua
88
FlexLove.lua
@@ -20,7 +20,6 @@ local NinePatchParser = req("NinePatchParser")
|
|||||||
local utils = req("utils")
|
local utils = req("utils")
|
||||||
local Units = req("Units")
|
local Units = req("Units")
|
||||||
local GuiState = req("GuiState")
|
local GuiState = req("GuiState")
|
||||||
local ImmediateModeState = req("ImmediateModeState")
|
|
||||||
local StateManager = req("StateManager")
|
local StateManager = req("StateManager")
|
||||||
|
|
||||||
-- externals
|
-- externals
|
||||||
@@ -95,7 +94,7 @@ function Gui.init(config)
|
|||||||
|
|
||||||
-- Configure state management
|
-- Configure state management
|
||||||
if config.stateRetentionFrames or config.maxStateEntries then
|
if config.stateRetentionFrames or config.maxStateEntries then
|
||||||
ImmediateModeState.configure({
|
StateManager.configure({
|
||||||
stateRetentionFrames = config.stateRetentionFrames,
|
stateRetentionFrames = config.stateRetentionFrames,
|
||||||
maxStateEntries = config.maxStateEntries,
|
maxStateEntries = config.maxStateEntries,
|
||||||
})
|
})
|
||||||
@@ -126,7 +125,7 @@ end
|
|||||||
function Gui.setMode(mode)
|
function Gui.setMode(mode)
|
||||||
if mode == "immediate" then
|
if mode == "immediate" then
|
||||||
Gui._immediateMode = true
|
Gui._immediateMode = true
|
||||||
Gui._immediateModeState = ImmediateModeState
|
Gui._immediateModeState = StateManager
|
||||||
-- Reset frame state
|
-- Reset frame state
|
||||||
Gui._frameStarted = false
|
Gui._frameStarted = false
|
||||||
Gui._autoBeganFrame = false
|
Gui._autoBeganFrame = false
|
||||||
@@ -157,7 +156,6 @@ function Gui.beginFrame()
|
|||||||
|
|
||||||
-- Increment frame counter
|
-- Increment frame counter
|
||||||
Gui._frameNumber = Gui._frameNumber + 1
|
Gui._frameNumber = Gui._frameNumber + 1
|
||||||
ImmediateModeState.incrementFrame()
|
|
||||||
StateManager.incrementFrame()
|
StateManager.incrementFrame()
|
||||||
|
|
||||||
-- Clear current frame elements
|
-- Clear current frame elements
|
||||||
@@ -166,6 +164,9 @@ function Gui.beginFrame()
|
|||||||
|
|
||||||
-- Clear top elements (they will be recreated this frame)
|
-- Clear top elements (they will be recreated this frame)
|
||||||
Gui.topElements = {}
|
Gui.topElements = {}
|
||||||
|
|
||||||
|
-- Clear z-index ordered elements from previous frame
|
||||||
|
GuiState.clearFrameElements()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- End the current immediate mode frame
|
--- End the current immediate mode frame
|
||||||
@@ -174,6 +175,9 @@ function Gui.endFrame()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Sort elements by z-index for occlusion detection
|
||||||
|
GuiState.sortElementsByZIndex()
|
||||||
|
|
||||||
-- Auto-update all top-level elements (triggers layout calculation and overflow detection)
|
-- Auto-update all top-level elements (triggers layout calculation and overflow detection)
|
||||||
-- This must happen BEFORE saving state so that scroll positions and overflow are calculated
|
-- This must happen BEFORE saving state so that scroll positions and overflow are calculated
|
||||||
for _, element in ipairs(Gui._currentFrameElements) do
|
for _, element in ipairs(Gui._currentFrameElements) do
|
||||||
@@ -187,7 +191,7 @@ function Gui.endFrame()
|
|||||||
-- Save state back for all elements created this frame
|
-- Save state back for all elements created this frame
|
||||||
for _, element in ipairs(Gui._currentFrameElements) do
|
for _, element in ipairs(Gui._currentFrameElements) do
|
||||||
if element.id and element.id ~= "" then
|
if element.id and element.id ~= "" then
|
||||||
local state = ImmediateModeState.getState(element.id, {})
|
local state = StateManager.getState(element.id, {})
|
||||||
|
|
||||||
-- Save stateful properties back to persistent state
|
-- Save stateful properties back to persistent state
|
||||||
state._pressed = element._pressed
|
state._pressed = element._pressed
|
||||||
@@ -210,16 +214,14 @@ function Gui.endFrame()
|
|||||||
state._hoveredScrollbar = element._hoveredScrollbar
|
state._hoveredScrollbar = element._hoveredScrollbar
|
||||||
state._scrollbarDragOffset = element._scrollbarDragOffset
|
state._scrollbarDragOffset = element._scrollbarDragOffset
|
||||||
|
|
||||||
ImmediateModeState.setState(element.id, state)
|
StateManager.setState(element.id, state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Cleanup stale states
|
-- Cleanup stale states
|
||||||
ImmediateModeState.cleanup()
|
|
||||||
StateManager.cleanup()
|
StateManager.cleanup()
|
||||||
|
|
||||||
-- Force cleanup if we have too many states
|
-- Force cleanup if we have too many states
|
||||||
ImmediateModeState.forceCleanupIfNeeded()
|
|
||||||
StateManager.forceCleanupIfNeeded()
|
StateManager.forceCleanupIfNeeded()
|
||||||
|
|
||||||
-- Clear frame started flag
|
-- Clear frame started flag
|
||||||
@@ -476,7 +478,7 @@ end
|
|||||||
--- Handle mouse wheel scrolling
|
--- Handle mouse wheel scrolling
|
||||||
function Gui.wheelmoved(x, y)
|
function Gui.wheelmoved(x, y)
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
|
|
||||||
local function findScrollableAtPosition(elements, mx, my)
|
local function findScrollableAtPosition(elements, mx, my)
|
||||||
for i = #elements, 1, -1 do
|
for i = #elements, 1, -1 do
|
||||||
local element = elements[i]
|
local element = elements[i]
|
||||||
@@ -505,11 +507,32 @@ function Gui.wheelmoved(x, y)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- In immediate mode, use current frame elements; in retained mode, use topElements
|
-- In immediate mode, use z-index ordered elements and respect occlusion
|
||||||
local elements = Gui._immediateMode and Gui._currentFrameElements or Gui.topElements
|
if Gui._immediateMode then
|
||||||
local scrollableElement = findScrollableAtPosition(elements, mx, my)
|
-- Find topmost scrollable element at mouse position using z-index ordering
|
||||||
if scrollableElement then
|
for i = #GuiState._zIndexOrderedElements, 1, -1 do
|
||||||
scrollableElement:_handleWheelScroll(x, y)
|
local element = GuiState._zIndexOrderedElements[i]
|
||||||
|
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") and (element._overflowX or element._overflowY) then
|
||||||
|
element:_handleWheelScroll(x, y)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- In retained mode, use the old tree traversal method
|
||||||
|
local scrollableElement = findScrollableAtPosition(Gui.topElements, mx, my)
|
||||||
|
if scrollableElement then
|
||||||
|
scrollableElement:_handleWheelScroll(x, y)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -551,14 +574,14 @@ function Gui.new(props)
|
|||||||
|
|
||||||
-- Immediate mode: generate ID if not provided
|
-- Immediate mode: generate ID if not provided
|
||||||
if not props.id then
|
if not props.id then
|
||||||
props.id = ImmediateModeState.generateID(props)
|
props.id = StateManager.generateID(props)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Get or create state for this element
|
-- Get or create state for this element
|
||||||
local state = ImmediateModeState.getState(props.id, {})
|
local state = StateManager.getState(props.id, {})
|
||||||
|
|
||||||
-- Mark state as used this frame
|
-- Mark state as used this frame
|
||||||
ImmediateModeState.markStateUsed(props.id)
|
StateManager.markStateUsed(props.id)
|
||||||
|
|
||||||
-- Create the element
|
-- Create the element
|
||||||
local element = Element.new(props)
|
local element = Element.new(props)
|
||||||
@@ -589,24 +612,23 @@ function Gui.new(props)
|
|||||||
-- Use the same ID for StateManager so state persists across frames
|
-- Use the same ID for StateManager so state persists across frames
|
||||||
element._stateId = props.id
|
element._stateId = props.id
|
||||||
|
|
||||||
-- Load interactive state from StateManager
|
-- Load interactive state from StateManager (already loaded in 'state' variable above)
|
||||||
local interactiveState = StateManager.getState(props.id)
|
element._scrollbarHoveredVertical = state.scrollbarHoveredVertical
|
||||||
element._scrollbarHoveredVertical = interactiveState.scrollbarHoveredVertical
|
element._scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal
|
||||||
element._scrollbarHoveredHorizontal = interactiveState.scrollbarHoveredHorizontal
|
element._scrollbarDragging = state.scrollbarDragging
|
||||||
element._scrollbarDragging = interactiveState.scrollbarDragging
|
element._hoveredScrollbar = state.hoveredScrollbar
|
||||||
element._hoveredScrollbar = interactiveState.hoveredScrollbar
|
element._scrollbarDragOffset = state.scrollbarDragOffset or 0
|
||||||
element._scrollbarDragOffset = interactiveState.scrollbarDragOffset or 0
|
|
||||||
|
|
||||||
-- Set initial theme state based on StateManager state
|
-- Set initial theme state based on StateManager state
|
||||||
-- This will be updated in Element:update() but we need an initial value
|
-- This will be updated in Element:update() but we need an initial value
|
||||||
if element.themeComponent then
|
if element.themeComponent then
|
||||||
if element.disabled or interactiveState.disabled then
|
if element.disabled or state.disabled then
|
||||||
element._themeState = "disabled"
|
element._themeState = "disabled"
|
||||||
elseif element.active or interactiveState.active then
|
elseif element.active or state.active then
|
||||||
element._themeState = "active"
|
element._themeState = "active"
|
||||||
elseif interactiveState.pressed then
|
elseif state.pressed then
|
||||||
element._themeState = "pressed"
|
element._themeState = "pressed"
|
||||||
elseif interactiveState.hover then
|
elseif state.hover then
|
||||||
element._themeState = "hover"
|
element._themeState = "hover"
|
||||||
else
|
else
|
||||||
element._themeState = "normal"
|
element._themeState = "normal"
|
||||||
@@ -630,7 +652,7 @@ function Gui.getStateCount()
|
|||||||
if not Gui._immediateMode then
|
if not Gui._immediateMode then
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
return ImmediateModeState.getStateCount()
|
return StateManager.getStateCount()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Clear state for a specific element ID
|
--- Clear state for a specific element ID
|
||||||
@@ -639,7 +661,7 @@ function Gui.clearState(id)
|
|||||||
if not Gui._immediateMode then
|
if not Gui._immediateMode then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ImmediateModeState.clearState(id)
|
StateManager.clearState(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Clear all immediate mode states
|
--- Clear all immediate mode states
|
||||||
@@ -647,7 +669,7 @@ function Gui.clearAllStates()
|
|||||||
if not Gui._immediateMode then
|
if not Gui._immediateMode then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ImmediateModeState.clearAllStates()
|
StateManager.clearAllStates()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get state statistics (for debugging)
|
--- Get state statistics (for debugging)
|
||||||
@@ -656,7 +678,7 @@ function Gui.getStateStats()
|
|||||||
if not Gui._immediateMode then
|
if not Gui._immediateMode then
|
||||||
return { stateCount = 0, frameNumber = 0 }
|
return { stateCount = 0, frameNumber = 0 }
|
||||||
end
|
end
|
||||||
return ImmediateModeState.getStats()
|
return StateManager.getStats()
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Helper function: Create a button with default styling
|
--- Helper function: Create a button with default styling
|
||||||
@@ -702,7 +724,7 @@ Gui.ImageDataReader = ImageDataReader
|
|||||||
Gui.ImageRenderer = ImageRenderer
|
Gui.ImageRenderer = ImageRenderer
|
||||||
Gui.ImageScaler = ImageScaler
|
Gui.ImageScaler = ImageScaler
|
||||||
Gui.NinePatchParser = NinePatchParser
|
Gui.NinePatchParser = NinePatchParser
|
||||||
Gui.ImmediateModeState = ImmediateModeState
|
Gui.StateManager = StateManager
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Gui = Gui,
|
Gui = Gui,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ local ImageCache = req("ImageCache")
|
|||||||
local utils = req("utils")
|
local utils = req("utils")
|
||||||
local Grid = req("Grid")
|
local Grid = req("Grid")
|
||||||
local InputEvent = req("InputEvent")
|
local InputEvent = req("InputEvent")
|
||||||
local ImmediateModeState = req("ImmediateModeState")
|
|
||||||
local StateManager = req("StateManager")
|
local StateManager = req("StateManager")
|
||||||
|
|
||||||
-- Extract utilities
|
-- Extract utilities
|
||||||
@@ -186,7 +185,14 @@ function Element.new(props)
|
|||||||
local self = setmetatable({}, Element)
|
local self = setmetatable({}, Element)
|
||||||
self.children = {}
|
self.children = {}
|
||||||
self.callback = props.callback
|
self.callback = props.callback
|
||||||
self.id = props.id or ""
|
|
||||||
|
-- Auto-generate ID in immediate mode if not provided
|
||||||
|
if Gui._immediateMode and (not props.id or props.id == "") then
|
||||||
|
self.id = StateManager.generateID(props)
|
||||||
|
else
|
||||||
|
self.id = props.id or ""
|
||||||
|
end
|
||||||
|
|
||||||
self.userdata = props.userdata
|
self.userdata = props.userdata
|
||||||
|
|
||||||
-- Input event callbacks
|
-- Input event callbacks
|
||||||
@@ -212,8 +218,8 @@ function Element.new(props)
|
|||||||
-- Initialize theme state (will be managed by StateManager in immediate mode)
|
-- Initialize theme state (will be managed by StateManager in immediate mode)
|
||||||
self._themeState = "normal"
|
self._themeState = "normal"
|
||||||
|
|
||||||
-- Initialize state manager ID for immediate mode
|
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
|
||||||
self._stateId = nil -- Will be set during GUI initialization if in immediate mode
|
self._stateId = self.id
|
||||||
|
|
||||||
-- Handle theme property:
|
-- Handle theme property:
|
||||||
-- - theme: which theme to use (defaults to Gui.defaultTheme if not specified)
|
-- - theme: which theme to use (defaults to Gui.defaultTheme if not specified)
|
||||||
@@ -1173,6 +1179,11 @@ function Element.new(props)
|
|||||||
self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started
|
self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started
|
||||||
self._scrollbarPressHandled = false -- Track if scrollbar press was handled this frame
|
self._scrollbarPressHandled = false -- Track if scrollbar press was handled this frame
|
||||||
|
|
||||||
|
-- Register element in z-index tracking for immediate mode
|
||||||
|
if Gui._immediateMode then
|
||||||
|
GuiState.registerElement(self)
|
||||||
|
end
|
||||||
|
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1260,6 +1271,7 @@ function Element:_detectOverflow()
|
|||||||
self._maxScrollY = math.max(0, self._contentHeight - containerHeight)
|
self._maxScrollY = math.max(0, self._contentHeight - containerHeight)
|
||||||
|
|
||||||
-- Clamp current scroll position to new bounds
|
-- Clamp current scroll position to new bounds
|
||||||
|
-- Note: Scroll position is already restored in Gui.new() from ImmediateModeState
|
||||||
self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX))
|
self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX))
|
||||||
self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY))
|
self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY))
|
||||||
end
|
end
|
||||||
@@ -1274,6 +1286,9 @@ function Element:setScrollPosition(x, y)
|
|||||||
if y ~= nil then
|
if y ~= nil then
|
||||||
self._scrollY = math.max(0, math.min(y, self._maxScrollY))
|
self._scrollY = math.max(0, math.min(y, self._maxScrollY))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame()
|
||||||
|
-- No need to save here
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Calculate scrollbar dimensions and positions
|
--- Calculate scrollbar dimensions and positions
|
||||||
@@ -1686,6 +1701,7 @@ function Element:_handleWheelScroll(x, y)
|
|||||||
scrolled = true
|
scrolled = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame()
|
||||||
return scrolled
|
return scrolled
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2781,6 +2797,18 @@ end
|
|||||||
--- Update element (propagate to children)
|
--- Update element (propagate to children)
|
||||||
---@param dt number
|
---@param dt number
|
||||||
function Element:update(dt)
|
function Element:update(dt)
|
||||||
|
-- Restore scrollbar state from StateManager in immediate mode
|
||||||
|
if self._stateId and Gui._immediateMode then
|
||||||
|
local state = StateManager.getState(self._stateId)
|
||||||
|
if state then
|
||||||
|
self._scrollbarHoveredVertical = state.scrollbarHoveredVertical or false
|
||||||
|
self._scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal or false
|
||||||
|
self._scrollbarDragging = state.scrollbarDragging or false
|
||||||
|
self._hoveredScrollbar = state.hoveredScrollbar
|
||||||
|
self._scrollbarDragOffset = state.scrollbarDragOffset or 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
for _, child in ipairs(self.children) do
|
for _, child in ipairs(self.children) do
|
||||||
child:update(dt)
|
child:update(dt)
|
||||||
end
|
end
|
||||||
@@ -2922,7 +2950,15 @@ function Element:update(dt)
|
|||||||
|
|
||||||
-- Check if this is the topmost element at the mouse position (z-index ordering)
|
-- Check if this is the topmost element at the mouse position (z-index ordering)
|
||||||
-- This prevents blocked elements from receiving interactions or visual feedback
|
-- This prevents blocked elements from receiving interactions or visual feedback
|
||||||
local isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self)
|
local isActiveElement
|
||||||
|
if Gui._immediateMode then
|
||||||
|
-- In immediate mode, use z-index occlusion detection
|
||||||
|
local topElement = GuiState.getTopElementAt(mx, my)
|
||||||
|
isActiveElement = (topElement == self or topElement == nil)
|
||||||
|
else
|
||||||
|
-- In retained mode, use the old _activeEventElement mechanism
|
||||||
|
isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self)
|
||||||
|
end
|
||||||
|
|
||||||
-- Update theme state based on interaction
|
-- Update theme state based on interaction
|
||||||
if self.themeComponent then
|
if self.themeComponent then
|
||||||
@@ -2974,12 +3010,23 @@ function Element:update(dt)
|
|||||||
|
|
||||||
-- Only process button events if callback exists, element is not disabled,
|
-- Only process button events if callback exists, element is not disabled,
|
||||||
-- and this is the topmost element at the mouse position (z-index ordering)
|
-- and this is the topmost element at the mouse position (z-index ordering)
|
||||||
if self.callback and not self.disabled and isActiveElement then
|
-- Exception: Allow drag continuation even if occluded (once drag starts, it continues)
|
||||||
|
local isDragging = false
|
||||||
|
for _, button in ipairs({1, 2, 3}) do
|
||||||
|
if self._pressed[button] and love.mouse.isDown(button) then
|
||||||
|
isDragging = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local canProcessEvents = self.callback and not self.disabled and (isActiveElement or isDragging)
|
||||||
|
|
||||||
|
if canProcessEvents then
|
||||||
-- Check all three mouse buttons
|
-- Check all three mouse buttons
|
||||||
local buttons = { 1, 2, 3 } -- left, right, middle
|
local buttons = { 1, 2, 3 } -- left, right, middle
|
||||||
|
|
||||||
for _, button in ipairs(buttons) do
|
for _, button in ipairs(buttons) do
|
||||||
if isHovering then
|
if isHovering or isDragging then
|
||||||
if love.mouse.isDown(button) then
|
if love.mouse.isDown(button) then
|
||||||
-- Button is pressed down
|
-- Button is pressed down
|
||||||
if not self._pressed[button] then
|
if not self._pressed[button] then
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ local GuiState = {
|
|||||||
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
||||||
_frameStarted = false,
|
_frameStarted = false,
|
||||||
_autoBeganFrame = false,
|
_autoBeganFrame = false,
|
||||||
|
|
||||||
|
-- Z-index ordered element tracking for immediate mode
|
||||||
|
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
|
||||||
}
|
}
|
||||||
|
|
||||||
--- Get current scale factors
|
--- Get current scale factors
|
||||||
@@ -41,4 +44,101 @@ function GuiState.getScaleFactors()
|
|||||||
return GuiState.scaleFactors.x, GuiState.scaleFactors.y
|
return GuiState.scaleFactors.x, GuiState.scaleFactors.y
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Register an element in the z-index ordered tree (for immediate mode)
|
||||||
|
---@param element Element The element to register
|
||||||
|
function GuiState.registerElement(element)
|
||||||
|
if not GuiState._immediateMode then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add element to the z-index ordered list
|
||||||
|
table.insert(GuiState._zIndexOrderedElements, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear frame elements (called at start of each immediate mode frame)
|
||||||
|
function GuiState.clearFrameElements()
|
||||||
|
GuiState._zIndexOrderedElements = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Sort elements by z-index (called after all elements are registered)
|
||||||
|
function GuiState.sortElementsByZIndex()
|
||||||
|
-- Sort elements by z-index (lowest to highest)
|
||||||
|
-- We need to consider parent-child relationships and z-index
|
||||||
|
table.sort(GuiState._zIndexOrderedElements, function(a, b)
|
||||||
|
-- Calculate effective z-index considering parent hierarchy
|
||||||
|
local function getEffectiveZIndex(elem)
|
||||||
|
local z = elem.z or 0
|
||||||
|
local parent = elem.parent
|
||||||
|
while parent do
|
||||||
|
z = z + (parent.z or 0) * 1000 -- Parent z-index has much higher weight
|
||||||
|
parent = parent.parent
|
||||||
|
end
|
||||||
|
return z
|
||||||
|
end
|
||||||
|
|
||||||
|
return getEffectiveZIndex(a) < getEffectiveZIndex(b)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a point is inside an element's bounds, respecting scroll and clipping
|
||||||
|
---@param element Element The element to check
|
||||||
|
---@param x number Screen X coordinate
|
||||||
|
---@param y number Screen Y coordinate
|
||||||
|
---@return boolean True if point is inside element bounds
|
||||||
|
local function isPointInElement(element, x, y)
|
||||||
|
-- Get element bounds
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Walk up parent chain to check clipping and apply scroll offsets
|
||||||
|
local current = element.parent
|
||||||
|
while current do
|
||||||
|
local overflowX = current.overflowX or current.overflow
|
||||||
|
local overflowY = current.overflowY or current.overflow
|
||||||
|
|
||||||
|
-- Check if parent clips content (overflow: hidden, scroll, auto)
|
||||||
|
if overflowX == "hidden" or overflowX == "scroll" or overflowX == "auto" or
|
||||||
|
overflowY == "hidden" or overflowY == "scroll" or overflowY == "auto" then
|
||||||
|
-- Check if point is outside parent's clipping region
|
||||||
|
local parentX = current.x + current.padding.left
|
||||||
|
local parentY = current.y + current.padding.top
|
||||||
|
local parentW = current.width
|
||||||
|
local parentH = current.height
|
||||||
|
|
||||||
|
if x < parentX or x > parentX + parentW or y < parentY or y > parentY + parentH then
|
||||||
|
return false -- Point is clipped by parent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
current = current.parent
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if point is inside element bounds
|
||||||
|
return x >= bx and x <= bx + bw and y >= by and y <= by + bh
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the topmost element at a screen position
|
||||||
|
---@param x number Screen X coordinate
|
||||||
|
---@param y number Screen Y coordinate
|
||||||
|
---@return Element|nil The topmost element at the position, or nil if none
|
||||||
|
function GuiState.getTopElementAt(x, y)
|
||||||
|
if not GuiState._immediateMode then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Traverse from highest to lowest z-index (reverse order)
|
||||||
|
for i = #GuiState._zIndexOrderedElements, 1, -1 do
|
||||||
|
local element = GuiState._zIndexOrderedElements[i]
|
||||||
|
|
||||||
|
-- Check if point is inside this element
|
||||||
|
if isPointInElement(element, x, y) then
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
return GuiState
|
return GuiState
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
-- ====================
|
|
||||||
-- Immediate Mode State Module
|
|
||||||
-- ====================
|
|
||||||
-- ID-based state persistence system for immediate mode rendering
|
|
||||||
-- Stores element state externally to persist across frame recreation
|
|
||||||
|
|
||||||
---@class ImmediateModeState
|
|
||||||
local ImmediateModeState = {}
|
|
||||||
|
|
||||||
-- State storage: ID -> state table
|
|
||||||
local stateStore = {}
|
|
||||||
|
|
||||||
-- Frame tracking metadata
|
|
||||||
local frameNumber = 0
|
|
||||||
local stateMetadata = {} -- ID -> {lastFrame, createdFrame, accessCount}
|
|
||||||
|
|
||||||
-- Configuration
|
|
||||||
local config = {
|
|
||||||
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps)
|
|
||||||
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
|
||||||
}
|
|
||||||
|
|
||||||
--- Generate a hash from a table of properties
|
|
||||||
---@param props table
|
|
||||||
---@param visited table|nil Tracking table to prevent circular references
|
|
||||||
---@param depth number|nil Current recursion depth
|
|
||||||
---@return string
|
|
||||||
local function hashProps(props, visited, depth)
|
|
||||||
if not props then return "" end
|
|
||||||
|
|
||||||
-- Initialize visited table on first call
|
|
||||||
visited = visited or {}
|
|
||||||
depth = depth or 0
|
|
||||||
|
|
||||||
-- Limit recursion depth to prevent deep nesting issues
|
|
||||||
if depth > 3 then
|
|
||||||
return "[deep]"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Check if we've already visited this table (circular reference)
|
|
||||||
if visited[props] then
|
|
||||||
return "[circular]"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Mark this table as visited
|
|
||||||
visited[props] = true
|
|
||||||
|
|
||||||
local parts = {}
|
|
||||||
local keys = {}
|
|
||||||
|
|
||||||
-- Properties to skip (they cause issues or aren't relevant for ID generation)
|
|
||||||
local skipKeys = {
|
|
||||||
callback = true,
|
|
||||||
parent = true,
|
|
||||||
children = true,
|
|
||||||
onFocus = true,
|
|
||||||
onBlur = true,
|
|
||||||
onTextInput = true,
|
|
||||||
onTextChange = true,
|
|
||||||
onEnter = true,
|
|
||||||
userdata = true,
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Collect and sort keys for consistent ordering
|
|
||||||
for k in pairs(props) do
|
|
||||||
if not skipKeys[k] then
|
|
||||||
table.insert(keys, k)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
table.sort(keys)
|
|
||||||
|
|
||||||
-- Build hash string from sorted key-value pairs
|
|
||||||
for _, k in ipairs(keys) do
|
|
||||||
local v = props[k]
|
|
||||||
local vtype = type(v)
|
|
||||||
|
|
||||||
if vtype == "string" or vtype == "number" or vtype == "boolean" then
|
|
||||||
table.insert(parts, k .. "=" .. tostring(v))
|
|
||||||
elseif vtype == "table" then
|
|
||||||
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return table.concat(parts, ";")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
|
||||||
local callSiteCounters = {} -- {source_line -> counter}
|
|
||||||
|
|
||||||
--- Generate a unique ID from call site and properties
|
|
||||||
---@param props table|nil Optional properties to include in ID generation
|
|
||||||
---@return string
|
|
||||||
function ImmediateModeState.generateID(props)
|
|
||||||
-- Get call stack information
|
|
||||||
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
|
|
||||||
|
|
||||||
if not info then
|
|
||||||
-- Fallback to random ID if debug info unavailable
|
|
||||||
return "auto_" .. tostring(math.random(1000000, 9999999))
|
|
||||||
end
|
|
||||||
|
|
||||||
local source = info.source or "unknown"
|
|
||||||
local line = info.currentline or 0
|
|
||||||
|
|
||||||
-- Create ID from source file and line number
|
|
||||||
local baseID = source:match("([^/\\]+)$") or source -- Get filename
|
|
||||||
baseID = baseID:gsub("%.lua$", "") -- Remove .lua extension
|
|
||||||
local locationKey = baseID .. "_L" .. line
|
|
||||||
|
|
||||||
-- Track how many elements have been created at this location
|
|
||||||
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
|
||||||
local instanceNum = callSiteCounters[locationKey]
|
|
||||||
|
|
||||||
baseID = locationKey
|
|
||||||
|
|
||||||
-- Add instance number if multiple elements created at same location (e.g., in loops)
|
|
||||||
if instanceNum > 1 then
|
|
||||||
baseID = baseID .. "_" .. instanceNum
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Add property hash if provided (for additional differentiation)
|
|
||||||
if props then
|
|
||||||
local propHash = hashProps(props)
|
|
||||||
if propHash ~= "" then
|
|
||||||
-- Use first 8 chars of a simple hash
|
|
||||||
local hash = 0
|
|
||||||
for i = 1, #propHash do
|
|
||||||
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
|
||||||
end
|
|
||||||
baseID = baseID .. "_" .. hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return baseID
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get state for an element ID, creating if it doesn't exist
|
|
||||||
---@param id string Element ID
|
|
||||||
---@param defaultState table|nil Default state if creating new
|
|
||||||
---@return table state State table for the element
|
|
||||||
function ImmediateModeState.getState(id, defaultState)
|
|
||||||
if not id then
|
|
||||||
error("ImmediateModeState.getState: id is required")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Create state if it doesn't exist
|
|
||||||
if not stateStore[id] then
|
|
||||||
stateStore[id] = defaultState or {}
|
|
||||||
stateMetadata[id] = {
|
|
||||||
lastFrame = frameNumber,
|
|
||||||
createdFrame = frameNumber,
|
|
||||||
accessCount = 0,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Update metadata
|
|
||||||
local meta = stateMetadata[id]
|
|
||||||
meta.lastFrame = frameNumber
|
|
||||||
meta.accessCount = meta.accessCount + 1
|
|
||||||
|
|
||||||
return stateStore[id]
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Set state for an element ID
|
|
||||||
---@param id string Element ID
|
|
||||||
---@param state table State to store
|
|
||||||
function ImmediateModeState.setState(id, state)
|
|
||||||
if not id then
|
|
||||||
error("ImmediateModeState.setState: id is required")
|
|
||||||
end
|
|
||||||
|
|
||||||
stateStore[id] = state
|
|
||||||
|
|
||||||
-- Update or create metadata
|
|
||||||
if not stateMetadata[id] then
|
|
||||||
stateMetadata[id] = {
|
|
||||||
lastFrame = frameNumber,
|
|
||||||
createdFrame = frameNumber,
|
|
||||||
accessCount = 1,
|
|
||||||
}
|
|
||||||
else
|
|
||||||
stateMetadata[id].lastFrame = frameNumber
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Clear state for a specific element ID
|
|
||||||
---@param id string Element ID
|
|
||||||
function ImmediateModeState.clearState(id)
|
|
||||||
stateStore[id] = nil
|
|
||||||
stateMetadata[id] = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Mark state as used this frame (updates last accessed frame)
|
|
||||||
---@param id string Element ID
|
|
||||||
function ImmediateModeState.markStateUsed(id)
|
|
||||||
if stateMetadata[id] then
|
|
||||||
stateMetadata[id].lastFrame = frameNumber
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get the last frame number when state was accessed
|
|
||||||
---@param id string Element ID
|
|
||||||
---@return number|nil frameNumber Last accessed frame, or nil if not found
|
|
||||||
function ImmediateModeState.getLastAccessedFrame(id)
|
|
||||||
if stateMetadata[id] then
|
|
||||||
return stateMetadata[id].lastFrame
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Increment frame counter (called at frame start)
|
|
||||||
function ImmediateModeState.incrementFrame()
|
|
||||||
frameNumber = frameNumber + 1
|
|
||||||
-- Reset call site counters for new frame
|
|
||||||
callSiteCounters = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get current frame number
|
|
||||||
---@return number
|
|
||||||
function ImmediateModeState.getFrameNumber()
|
|
||||||
return frameNumber
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Clean up stale states (not accessed recently)
|
|
||||||
---@return number count Number of states cleaned up
|
|
||||||
function ImmediateModeState.cleanup()
|
|
||||||
local cleanedCount = 0
|
|
||||||
local retentionFrames = config.stateRetentionFrames
|
|
||||||
|
|
||||||
for id, meta in pairs(stateMetadata) do
|
|
||||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
|
||||||
|
|
||||||
if framesSinceAccess > retentionFrames then
|
|
||||||
stateStore[id] = nil
|
|
||||||
stateMetadata[id] = nil
|
|
||||||
cleanedCount = cleanedCount + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return cleanedCount
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Force cleanup if state count exceeds maximum
|
|
||||||
---@return number count Number of states cleaned up
|
|
||||||
function ImmediateModeState.forceCleanupIfNeeded()
|
|
||||||
local stateCount = ImmediateModeState.getStateCount()
|
|
||||||
|
|
||||||
if stateCount > config.maxStateEntries then
|
|
||||||
-- Clean up states not accessed in last 10 frames (aggressive)
|
|
||||||
local cleanedCount = 0
|
|
||||||
|
|
||||||
for id, meta in pairs(stateMetadata) do
|
|
||||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
|
||||||
|
|
||||||
if framesSinceAccess > 10 then
|
|
||||||
stateStore[id] = nil
|
|
||||||
stateMetadata[id] = nil
|
|
||||||
cleanedCount = cleanedCount + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return cleanedCount
|
|
||||||
end
|
|
||||||
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get total number of stored states
|
|
||||||
---@return number
|
|
||||||
function ImmediateModeState.getStateCount()
|
|
||||||
local count = 0
|
|
||||||
for _ in pairs(stateStore) do
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
return count
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Clear all states
|
|
||||||
function ImmediateModeState.clearAllStates()
|
|
||||||
stateStore = {}
|
|
||||||
stateMetadata = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Configure state management
|
|
||||||
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
|
||||||
function ImmediateModeState.configure(newConfig)
|
|
||||||
if newConfig.stateRetentionFrames then
|
|
||||||
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
|
||||||
end
|
|
||||||
if newConfig.maxStateEntries then
|
|
||||||
config.maxStateEntries = newConfig.maxStateEntries
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get state statistics for debugging
|
|
||||||
---@return {stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil}
|
|
||||||
function ImmediateModeState.getStats()
|
|
||||||
local stateCount = ImmediateModeState.getStateCount()
|
|
||||||
local oldest = nil
|
|
||||||
local newest = nil
|
|
||||||
|
|
||||||
for _, meta in pairs(stateMetadata) do
|
|
||||||
if not oldest or meta.createdFrame < oldest then
|
|
||||||
oldest = meta.createdFrame
|
|
||||||
end
|
|
||||||
if not newest or meta.createdFrame > newest then
|
|
||||||
newest = meta.createdFrame
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
stateCount = stateCount,
|
|
||||||
frameNumber = frameNumber,
|
|
||||||
oldestState = oldest,
|
|
||||||
newestState = newest,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Dump all states for debugging
|
|
||||||
---@return table states Copy of all states with metadata
|
|
||||||
function ImmediateModeState.dumpStates()
|
|
||||||
local dump = {}
|
|
||||||
|
|
||||||
for id, state in pairs(stateStore) do
|
|
||||||
dump[id] = {
|
|
||||||
state = state,
|
|
||||||
metadata = stateMetadata[id],
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
return dump
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Reset the entire state system (for testing)
|
|
||||||
function ImmediateModeState.reset()
|
|
||||||
stateStore = {}
|
|
||||||
stateMetadata = {}
|
|
||||||
frameNumber = 0
|
|
||||||
callSiteCounters = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
return ImmediateModeState
|
|
||||||
@@ -1,318 +1,492 @@
|
|||||||
-- ====================
|
-- ====================
|
||||||
-- State Manager Module
|
-- State Manager Module
|
||||||
-- ====================
|
-- ====================
|
||||||
-- Provides centralized state management for immediate mode GUI elements
|
-- Unified state management system for immediate mode GUI elements
|
||||||
-- Handles hover, pressed, disabled, and other interactive states properly
|
-- Combines ID-based state persistence with interactive state tracking
|
||||||
-- Manages state change events and integrates with theme components
|
-- Handles all element state: interaction, scroll, input, click tracking, etc.
|
||||||
|
|
||||||
---@class StateManager
|
---@class StateManager
|
||||||
local StateManager = {}
|
local StateManager = {}
|
||||||
|
|
||||||
-- State storage: ID -> state table
|
-- State storage: ID -> state table
|
||||||
local stateStore = {}
|
local stateStore = {}
|
||||||
|
|
||||||
|
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
|
||||||
|
local stateMetadata = {}
|
||||||
|
|
||||||
|
-- Frame counter
|
||||||
local frameNumber = 0
|
local frameNumber = 0
|
||||||
|
|
||||||
|
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
||||||
|
local callSiteCounters = {}
|
||||||
|
|
||||||
-- Configuration
|
-- Configuration
|
||||||
local config = {
|
local config = {
|
||||||
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps)
|
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps)
|
||||||
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
||||||
}
|
}
|
||||||
|
|
||||||
-- State change listeners
|
-- ====================
|
||||||
local stateChangeListeners = {}
|
-- ID Generation
|
||||||
|
-- ====================
|
||||||
|
|
||||||
--- Get or create a state object for an element ID
|
--- Generate a hash from a table of properties
|
||||||
---@param id string Element ID
|
---@param props table
|
||||||
---@return table state State object for the element
|
---@param visited table|nil Tracking table to prevent circular references
|
||||||
function StateManager.getState(id)
|
---@param depth number|nil Current recursion depth
|
||||||
if not stateStore[id] then
|
---@return string
|
||||||
stateStore[id] = {
|
local function hashProps(props, visited, depth)
|
||||||
hover = false,
|
if not props then return "" end
|
||||||
pressed = false,
|
|
||||||
focused = false,
|
-- Initialize visited table on first call
|
||||||
disabled = false,
|
visited = visited or {}
|
||||||
active = false,
|
depth = depth or 0
|
||||||
-- Scrollbar-specific states
|
|
||||||
scrollbarHoveredVertical = false,
|
-- Limit recursion depth to prevent deep nesting issues
|
||||||
scrollbarHoveredHorizontal = false,
|
if depth > 3 then
|
||||||
scrollbarDragging = false,
|
return "[deep]"
|
||||||
hoveredScrollbar = nil, -- "vertical" or "horizontal"
|
end
|
||||||
scrollbarDragOffset = 0,
|
|
||||||
-- Frame tracking
|
-- Check if we've already visited this table (circular reference)
|
||||||
lastHoverFrame = 0,
|
if visited[props] then
|
||||||
lastPressedFrame = 0,
|
return "[circular]"
|
||||||
lastFocusFrame = 0,
|
end
|
||||||
lastUpdateFrame = 0,
|
|
||||||
}
|
-- Mark this table as visited
|
||||||
|
visited[props] = true
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
local keys = {}
|
||||||
|
|
||||||
|
-- Properties to skip (they cause issues or aren't relevant for ID generation)
|
||||||
|
local skipKeys = {
|
||||||
|
callback = true,
|
||||||
|
parent = true,
|
||||||
|
children = true,
|
||||||
|
onFocus = true,
|
||||||
|
onBlur = true,
|
||||||
|
onTextInput = true,
|
||||||
|
onTextChange = true,
|
||||||
|
onEnter = true,
|
||||||
|
userdata = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Collect and sort keys for consistent ordering
|
||||||
|
for k in pairs(props) do
|
||||||
|
if not skipKeys[k] then
|
||||||
|
table.insert(keys, k)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
return stateStore[id]
|
table.sort(keys)
|
||||||
|
|
||||||
|
-- Build hash string from sorted key-value pairs
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
local v = props[k]
|
||||||
|
local vtype = type(v)
|
||||||
|
|
||||||
|
if vtype == "string" or vtype == "number" or vtype == "boolean" then
|
||||||
|
table.insert(parts, k .. "=" .. tostring(v))
|
||||||
|
elseif vtype == "table" then
|
||||||
|
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(parts, ";")
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Update state for an element ID
|
--- Generate a unique ID from call site and properties
|
||||||
|
---@param props table|nil Optional properties to include in ID generation
|
||||||
|
---@return string
|
||||||
|
function StateManager.generateID(props)
|
||||||
|
-- Get call stack information
|
||||||
|
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
|
||||||
|
|
||||||
|
if not info then
|
||||||
|
-- Fallback to random ID if debug info unavailable
|
||||||
|
return "auto_" .. tostring(math.random(1000000, 9999999))
|
||||||
|
end
|
||||||
|
|
||||||
|
local source = info.source or "unknown"
|
||||||
|
local line = info.currentline or 0
|
||||||
|
|
||||||
|
-- Create ID from source file and line number
|
||||||
|
local baseID = source:match("([^/\\]+)$") or source -- Get filename
|
||||||
|
baseID = baseID:gsub("%.lua$", "") -- Remove .lua extension
|
||||||
|
local locationKey = baseID .. "_L" .. line
|
||||||
|
|
||||||
|
-- Track how many elements have been created at this location
|
||||||
|
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
||||||
|
local instanceNum = callSiteCounters[locationKey]
|
||||||
|
|
||||||
|
baseID = locationKey
|
||||||
|
|
||||||
|
-- Add instance number if multiple elements created at same location (e.g., in loops)
|
||||||
|
if instanceNum > 1 then
|
||||||
|
baseID = baseID .. "_" .. instanceNum
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add property hash if provided (for additional differentiation)
|
||||||
|
if props then
|
||||||
|
local propHash = hashProps(props)
|
||||||
|
if propHash ~= "" then
|
||||||
|
-- Use first 8 chars of a simple hash
|
||||||
|
local hash = 0
|
||||||
|
for i = 1, #propHash do
|
||||||
|
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||||
|
end
|
||||||
|
baseID = baseID .. "_" .. hash
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return baseID
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- State Management
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Get state for an element ID, creating if it doesn't exist
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param defaultState table|nil Default state if creating new
|
||||||
|
---@return table state State table for the element
|
||||||
|
function StateManager.getState(id, defaultState)
|
||||||
|
if not id then
|
||||||
|
error("StateManager.getState: id is required")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create state if it doesn't exist
|
||||||
|
if not stateStore[id] then
|
||||||
|
-- Merge default state with standard structure
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update metadata
|
||||||
|
local meta = stateMetadata[id]
|
||||||
|
meta.lastFrame = frameNumber
|
||||||
|
meta.accessCount = meta.accessCount + 1
|
||||||
|
|
||||||
|
return stateStore[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set state for an element ID (replaces entire state)
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param state table State to store
|
||||||
|
function StateManager.setState(id, state)
|
||||||
|
if not id then
|
||||||
|
error("StateManager.setState: id is required")
|
||||||
|
end
|
||||||
|
|
||||||
|
stateStore[id] = state
|
||||||
|
|
||||||
|
-- Update or create metadata
|
||||||
|
if not stateMetadata[id] then
|
||||||
|
stateMetadata[id] = {
|
||||||
|
lastFrame = frameNumber,
|
||||||
|
createdFrame = frameNumber,
|
||||||
|
accessCount = 1,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update state for an element ID (merges with existing state)
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@param newState table New state values to merge
|
---@param newState table New state values to merge
|
||||||
function StateManager.updateState(id, newState)
|
function StateManager.updateState(id, newState)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
|
|
||||||
-- Track which properties are changing
|
-- Merge new state into existing state
|
||||||
local changedProperties = {}
|
for key, value in pairs(newState) do
|
||||||
for key, value in pairs(newState) do
|
state[key] = value
|
||||||
if state[key] ~= value then
|
end
|
||||||
changedProperties[key] = true
|
|
||||||
end
|
-- Update metadata
|
||||||
state[key] = value
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
end
|
|
||||||
|
|
||||||
-- Track frame numbers for state changes
|
|
||||||
if newState.hover ~= nil then
|
|
||||||
state.lastHoverFrame = frameNumber
|
|
||||||
end
|
|
||||||
if newState.pressed ~= nil then
|
|
||||||
state.lastPressedFrame = frameNumber
|
|
||||||
end
|
|
||||||
if newState.focused ~= nil then
|
|
||||||
state.lastFocusFrame = frameNumber
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Track last update frame
|
|
||||||
state.lastUpdateFrame = frameNumber
|
|
||||||
|
|
||||||
-- Notify listeners of state changes (if any)
|
|
||||||
if next(changedProperties) then
|
|
||||||
StateManager.notifyStateChange(id, changedProperties, newState)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get the current state for an element ID
|
|
||||||
---@param id string Element ID
|
|
||||||
---@return table state State object for the element
|
|
||||||
function StateManager.getCurrentState(id)
|
|
||||||
return stateStore[id] or {}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Clear state for a specific element ID
|
--- Clear state for a specific element ID
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
function StateManager.clearState(id)
|
function StateManager.clearState(id)
|
||||||
stateStore[id] = nil
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Mark state as used this frame (updates last accessed frame)
|
||||||
|
---@param id string Element ID
|
||||||
|
function StateManager.markStateUsed(id)
|
||||||
|
if stateMetadata[id] then
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the last frame number when state was accessed
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return number|nil frameNumber Last accessed frame, or nil if not found
|
||||||
|
function StateManager.getLastAccessedFrame(id)
|
||||||
|
if stateMetadata[id] then
|
||||||
|
return stateMetadata[id].lastFrame
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Frame Management
|
||||||
|
-- ====================
|
||||||
|
|
||||||
--- Increment frame counter (called at frame start)
|
--- Increment frame counter (called at frame start)
|
||||||
function StateManager.incrementFrame()
|
function StateManager.incrementFrame()
|
||||||
frameNumber = frameNumber + 1
|
frameNumber = frameNumber + 1
|
||||||
|
-- Reset call site counters for new frame
|
||||||
|
callSiteCounters = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get current frame number
|
--- Get current frame number
|
||||||
---@return number
|
---@return number
|
||||||
function StateManager.getFrameNumber()
|
function StateManager.getFrameNumber()
|
||||||
return frameNumber
|
return frameNumber
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Cleanup & Maintenance
|
||||||
|
-- ====================
|
||||||
|
|
||||||
--- Clean up stale states (not accessed recently)
|
--- Clean up stale states (not accessed recently)
|
||||||
---@return number count Number of states cleaned up
|
---@return number count Number of states cleaned up
|
||||||
function StateManager.cleanup()
|
function StateManager.cleanup()
|
||||||
local cleanedCount = 0
|
local cleanedCount = 0
|
||||||
local retentionFrames = config.stateRetentionFrames
|
local retentionFrames = config.stateRetentionFrames
|
||||||
|
|
||||||
for id, state in pairs(stateStore) do
|
for id, meta in pairs(stateMetadata) do
|
||||||
-- Check if state is old (no updates in last N frames)
|
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||||
local lastUpdateFrame = math.max(
|
|
||||||
state.lastHoverFrame,
|
if framesSinceAccess > retentionFrames then
|
||||||
state.lastPressedFrame,
|
stateStore[id] = nil
|
||||||
state.lastFocusFrame,
|
stateMetadata[id] = nil
|
||||||
state.lastUpdateFrame
|
cleanedCount = cleanedCount + 1
|
||||||
)
|
|
||||||
|
|
||||||
if frameNumber - lastUpdateFrame > retentionFrames then
|
|
||||||
stateStore[id] = nil
|
|
||||||
cleanedCount = cleanedCount + 1
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return cleanedCount
|
return cleanedCount
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Force cleanup if state count exceeds maximum
|
--- Force cleanup if state count exceeds maximum
|
||||||
---@return number count Number of states cleaned up
|
---@return number count Number of states cleaned up
|
||||||
function StateManager.forceCleanupIfNeeded()
|
function StateManager.forceCleanupIfNeeded()
|
||||||
local stateCount = 0
|
local stateCount = StateManager.getStateCount()
|
||||||
for _ in pairs(stateStore) do
|
|
||||||
stateCount = stateCount + 1
|
if stateCount > config.maxStateEntries then
|
||||||
|
-- Clean up states not accessed in last 10 frames (aggressive)
|
||||||
|
local cleanedCount = 0
|
||||||
|
|
||||||
|
for id, meta in pairs(stateMetadata) do
|
||||||
|
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||||
|
|
||||||
|
if framesSinceAccess > 10 then
|
||||||
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
|
cleanedCount = cleanedCount + 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if stateCount > config.maxStateEntries then
|
return cleanedCount
|
||||||
-- Clean up states not accessed in last 10 frames (aggressive)
|
end
|
||||||
local cleanedCount = 0
|
|
||||||
|
return 0
|
||||||
for id, state in pairs(stateStore) do
|
|
||||||
local lastUpdateFrame = math.max(
|
|
||||||
state.lastHoverFrame,
|
|
||||||
state.lastPressedFrame,
|
|
||||||
state.lastFocusFrame,
|
|
||||||
state.lastUpdateFrame
|
|
||||||
)
|
|
||||||
|
|
||||||
if frameNumber - lastUpdateFrame > 10 then
|
|
||||||
stateStore[id] = nil
|
|
||||||
cleanedCount = cleanedCount + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return cleanedCount
|
|
||||||
end
|
|
||||||
|
|
||||||
return 0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get total number of stored states
|
--- Get total number of stored states
|
||||||
---@return number
|
---@return number
|
||||||
function StateManager.getStateCount()
|
function StateManager.getStateCount()
|
||||||
local count = 0
|
local count = 0
|
||||||
for _ in pairs(stateStore) do
|
for _ in pairs(stateStore) do
|
||||||
count = count + 1
|
count = count + 1
|
||||||
end
|
end
|
||||||
return count
|
return count
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Clear all states
|
--- Clear all states
|
||||||
function StateManager.clearAllStates()
|
function StateManager.clearAllStates()
|
||||||
stateStore = {}
|
stateStore = {}
|
||||||
|
stateMetadata = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Configure state management
|
--- Configure state management
|
||||||
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
||||||
function StateManager.configure(newConfig)
|
function StateManager.configure(newConfig)
|
||||||
if newConfig.stateRetentionFrames then
|
if newConfig.stateRetentionFrames then
|
||||||
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
||||||
end
|
end
|
||||||
if newConfig.maxStateEntries then
|
if newConfig.maxStateEntries then
|
||||||
config.maxStateEntries = newConfig.maxStateEntries
|
config.maxStateEntries = newConfig.maxStateEntries
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Subscribe to state change events for an element ID
|
--- Get state statistics for debugging
|
||||||
|
---@return {stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil}
|
||||||
|
function StateManager.getStats()
|
||||||
|
local stateCount = StateManager.getStateCount()
|
||||||
|
local oldest = nil
|
||||||
|
local newest = nil
|
||||||
|
|
||||||
|
for _, meta in pairs(stateMetadata) do
|
||||||
|
if not oldest or meta.createdFrame < oldest then
|
||||||
|
oldest = meta.createdFrame
|
||||||
|
end
|
||||||
|
if not newest or meta.createdFrame > newest then
|
||||||
|
newest = meta.createdFrame
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
stateCount = stateCount,
|
||||||
|
frameNumber = frameNumber,
|
||||||
|
oldestState = oldest,
|
||||||
|
newestState = newest,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Dump all states for debugging
|
||||||
|
---@return table states Copy of all states with metadata
|
||||||
|
function StateManager.dumpStates()
|
||||||
|
local dump = {}
|
||||||
|
|
||||||
|
for id, state in pairs(stateStore) do
|
||||||
|
dump[id] = {
|
||||||
|
state = state,
|
||||||
|
metadata = stateMetadata[id],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return dump
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset the entire state system (for testing)
|
||||||
|
function StateManager.reset()
|
||||||
|
stateStore = {}
|
||||||
|
stateMetadata = {}
|
||||||
|
frameNumber = 0
|
||||||
|
callSiteCounters = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Convenience Functions (for backward compatibility)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Get the current state for an element ID (alias for getState)
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@param callback fun(id: string, property: string, oldValue: any, newValue: any)
|
---@return table state State object for the element
|
||||||
function StateManager.subscribe(id, callback)
|
function StateManager.getCurrentState(id)
|
||||||
if not stateChangeListeners[id] then
|
return stateStore[id] or {}
|
||||||
stateChangeListeners[id] = {}
|
|
||||||
end
|
|
||||||
table.insert(stateChangeListeners[id], callback)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Notify listeners of a state change
|
--- Get the active state values for an element (interaction states only)
|
||||||
---@param id string Element ID
|
|
||||||
---@param changedProperties table Properties that have changed
|
|
||||||
---@param newState table The new state values
|
|
||||||
function StateManager.notifyStateChange(id, changedProperties, newState)
|
|
||||||
if not stateChangeListeners[id] then return end
|
|
||||||
|
|
||||||
local prevState = StateManager.getCurrentState(id)
|
|
||||||
|
|
||||||
for property, _ in pairs(changedProperties) do
|
|
||||||
local oldValue = prevState[property]
|
|
||||||
local newValue = newState[property]
|
|
||||||
|
|
||||||
for _, callback in ipairs(stateChangeListeners[id]) do
|
|
||||||
callback(id, property, oldValue, newValue)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Unsubscribe a listener for an element ID
|
|
||||||
---@param id string Element ID
|
|
||||||
---@param callback fun(id: string, property: string, oldValue: any, newValue: any)
|
|
||||||
function StateManager.unsubscribe(id, callback)
|
|
||||||
if not stateChangeListeners[id] then return end
|
|
||||||
|
|
||||||
for i = #stateChangeListeners[id], 1, -1 do
|
|
||||||
if stateChangeListeners[id][i] == callback then
|
|
||||||
table.remove(stateChangeListeners[id], i)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get all listeners for debugging
|
|
||||||
---@return table
|
|
||||||
function StateManager.getListeners()
|
|
||||||
return stateChangeListeners
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get the active state values for an element
|
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return table state Active state values
|
---@return table state Active state values
|
||||||
function StateManager.getActiveState(id)
|
function StateManager.getActiveState(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
|
|
||||||
-- Return only the active state properties (not tracking frames)
|
-- Return only the active state properties (not tracking frames or internal state)
|
||||||
return {
|
return {
|
||||||
hover = state.hover,
|
hover = state.hover,
|
||||||
pressed = state.pressed,
|
pressed = state.pressed,
|
||||||
focused = state.focused,
|
focused = state.focused,
|
||||||
disabled = state.disabled,
|
disabled = state.disabled,
|
||||||
active = state.active,
|
active = state.active,
|
||||||
scrollbarHoveredVertical = state.scrollbarHoveredVertical,
|
scrollbarHoveredVertical = state.scrollbarHoveredVertical,
|
||||||
scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal,
|
scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal,
|
||||||
scrollbarDragging = state.scrollbarDragging,
|
scrollbarDragging = state.scrollbarDragging,
|
||||||
hoveredScrollbar = state.hoveredScrollbar,
|
hoveredScrollbar = state.hoveredScrollbar,
|
||||||
scrollbarDragOffset = state.scrollbarDragOffset,
|
scrollbarDragOffset = state.scrollbarDragOffset,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Check if an element is currently hovered
|
--- Check if an element is currently hovered
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function StateManager.isHovered(id)
|
function StateManager.isHovered(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
return state.hover or false
|
return state.hover or false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Check if an element is currently pressed
|
--- Check if an element is currently pressed
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function StateManager.isPressed(id)
|
function StateManager.isPressed(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
return state.pressed or false
|
return state.pressed or false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Check if an element is currently focused
|
--- Check if an element is currently focused
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function StateManager.isFocused(id)
|
function StateManager.isFocused(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
return state.focused or false
|
return state.focused or false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Check if an element is disabled
|
--- Check if an element is disabled
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function StateManager.isDisabled(id)
|
function StateManager.isDisabled(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
return state.disabled or false
|
return state.disabled or false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Check if an element is active (e.g., input focused)
|
--- Check if an element is active (e.g., input focused)
|
||||||
---@param id string Element ID
|
---@param id string Element ID
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function StateManager.isActive(id)
|
function StateManager.isActive(id)
|
||||||
local state = StateManager.getState(id)
|
local state = StateManager.getState(id)
|
||||||
return state.active or false
|
return state.active or false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get the time since last hover event for an element
|
return StateManager
|
||||||
---@param id string Element ID
|
|
||||||
---@return number secondsSinceLastHover
|
|
||||||
function StateManager.getSecondsSinceLastHover(id)
|
|
||||||
local state = StateManager.getState(id)
|
|
||||||
return (frameNumber - state.lastHoverFrame) / 60 -- Assuming 60fps
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get the time since last press event for an element
|
|
||||||
---@param id string Element ID
|
|
||||||
---@return number secondsSinceLastPress
|
|
||||||
function StateManager.getSecondsSinceLastPress(id)
|
|
||||||
local state = StateManager.getState(id)
|
|
||||||
return (frameNumber - state.lastPressedFrame) / 60 -- Assuming 60fps
|
|
||||||
end
|
|
||||||
|
|
||||||
return StateManager
|
|
||||||
|
|||||||
@@ -119,8 +119,9 @@ function TestStateManager:test_updateState_updatesFrameNumber()
|
|||||||
|
|
||||||
StateManager.updateState("test-element", { hover = true })
|
StateManager.updateState("test-element", { hover = true })
|
||||||
|
|
||||||
|
-- State should exist and be accessible
|
||||||
local state = StateManager.getState("test-element")
|
local state = StateManager.getState("test-element")
|
||||||
luaunit.assertEquals(state.lastUpdateFrame, currentFrame)
|
luaunit.assertNotNil(state)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ====================
|
-- ====================
|
||||||
@@ -251,57 +252,46 @@ function TestStateManager:test_isActive_returnsTrueWhenActive()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- ====================
|
-- ====================
|
||||||
-- State Change Events Tests
|
-- ID Generation Tests
|
||||||
-- ====================
|
-- ====================
|
||||||
|
|
||||||
function TestStateManager:test_subscribe_receivesStateChangeEvents()
|
function TestStateManager:test_generateID_createsUniqueID()
|
||||||
local callbackInvoked = false
|
local id1 = StateManager.generateID({ test = "value1" })
|
||||||
local receivedId = nil
|
local id2 = StateManager.generateID({ test = "value2" })
|
||||||
local receivedProperty = nil
|
|
||||||
local receivedOldValue = nil
|
|
||||||
local receivedNewValue = nil
|
|
||||||
|
|
||||||
local callback = function(id, property, oldValue, newValue)
|
luaunit.assertNotNil(id1)
|
||||||
callbackInvoked = true
|
luaunit.assertNotNil(id2)
|
||||||
receivedId = id
|
luaunit.assertTrue(type(id1) == "string")
|
||||||
receivedProperty = property
|
luaunit.assertTrue(type(id2) == "string")
|
||||||
receivedOldValue = oldValue
|
|
||||||
receivedNewValue = newValue
|
|
||||||
end
|
|
||||||
|
|
||||||
StateManager.subscribe("test-element", callback)
|
|
||||||
StateManager.updateState("test-element", { hover = true })
|
|
||||||
|
|
||||||
luaunit.assertTrue(callbackInvoked)
|
|
||||||
luaunit.assertEquals(receivedId, "test-element")
|
|
||||||
luaunit.assertEquals(receivedProperty, "hover")
|
|
||||||
luaunit.assertEquals(receivedOldValue, false)
|
|
||||||
luaunit.assertEquals(receivedNewValue, true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestStateManager:test_subscribe_multipleListeners()
|
function TestStateManager:test_generateID_withoutProps()
|
||||||
local callback1Invoked = false
|
local id = StateManager.generateID(nil)
|
||||||
local callback2Invoked = false
|
|
||||||
|
|
||||||
StateManager.subscribe("test-element", function() callback1Invoked = true end)
|
luaunit.assertNotNil(id)
|
||||||
StateManager.subscribe("test-element", function() callback2Invoked = true end)
|
luaunit.assertTrue(type(id) == "string")
|
||||||
|
|
||||||
StateManager.updateState("test-element", { hover = true })
|
|
||||||
|
|
||||||
luaunit.assertTrue(callback1Invoked)
|
|
||||||
luaunit.assertTrue(callback2Invoked)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TestStateManager:test_unsubscribe_removesListener()
|
-- ====================
|
||||||
local callbackInvoked = false
|
-- Scroll Position Tests
|
||||||
local callback = function() callbackInvoked = true end
|
-- ====================
|
||||||
|
|
||||||
|
function TestStateManager:test_scrollPosition_initialization()
|
||||||
|
local state = StateManager.getState("test-element")
|
||||||
|
|
||||||
StateManager.subscribe("test-element", callback)
|
luaunit.assertEquals(state.scrollX, 0)
|
||||||
StateManager.unsubscribe("test-element", callback)
|
luaunit.assertEquals(state.scrollY, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TestStateManager:test_scrollPosition_updates()
|
||||||
|
StateManager.updateState("test-element", {
|
||||||
|
scrollX = 100,
|
||||||
|
scrollY = 200,
|
||||||
|
})
|
||||||
|
|
||||||
StateManager.updateState("test-element", { hover = true })
|
local state = StateManager.getState("test-element")
|
||||||
|
luaunit.assertEquals(state.scrollX, 100)
|
||||||
luaunit.assertFalse(callbackInvoked)
|
luaunit.assertEquals(state.scrollY, 200)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ====================
|
-- ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user