immediate mode state management cleanup

This commit is contained in:
Michael Freno
2025-11-06 00:11:43 -05:00
parent 0177195061
commit 3a82bcee8e
6 changed files with 629 additions and 638 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
-- ==================== -- ====================