740 lines
20 KiB
Lua
740 lines
20 KiB
Lua
---@class StateManager
|
|
local StateManager = {}
|
|
|
|
-- Load error handler (loaded lazily since it's in a sibling module)
|
|
local ErrorHandler
|
|
|
|
-- State storage: ID -> state table
|
|
local stateStore = {}
|
|
|
|
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
|
|
local stateMetadata = {}
|
|
|
|
-- Frame counter
|
|
local frameNumber = 0
|
|
|
|
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
|
local callSiteCounters = {}
|
|
|
|
-- Configuration
|
|
local config = {
|
|
stateRetentionFrames = 2, -- Keep unused state for 2 frames
|
|
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
|
}
|
|
|
|
-- Default state values (sparse storage - don't store these)
|
|
local stateDefaults = {
|
|
-- Interaction states
|
|
hover = false,
|
|
pressed = false,
|
|
focused = false,
|
|
disabled = false,
|
|
active = false,
|
|
|
|
-- Scrollbar states
|
|
scrollbarHoveredVertical = false,
|
|
scrollbarHoveredHorizontal = false,
|
|
scrollbarDragging = false,
|
|
hoveredScrollbar = nil,
|
|
scrollbarDragOffset = 0,
|
|
dragStartMouseX = 0,
|
|
dragStartMouseY = 0,
|
|
dragStartScrollX = 0,
|
|
dragStartScrollY = 0,
|
|
|
|
-- Scroll position
|
|
scrollX = 0,
|
|
scrollY = 0,
|
|
_scrollX = 0,
|
|
_scrollY = 0,
|
|
|
|
-- Click tracking
|
|
_clickCount = 0,
|
|
_lastClickTime = nil,
|
|
_lastClickButton = nil,
|
|
|
|
-- Internal states
|
|
_hovered = nil,
|
|
_focused = nil,
|
|
_cursorPosition = nil,
|
|
_selectionStart = nil,
|
|
_selectionEnd = nil,
|
|
_textBuffer = "",
|
|
_cursorBlinkTimer = 0,
|
|
_cursorVisible = true,
|
|
_cursorBlinkPaused = false,
|
|
_cursorBlinkPauseTimer = 0,
|
|
|
|
-- Retained children references (for mixed-mode trees)
|
|
retainedChildren = nil,
|
|
}
|
|
|
|
--- Check if a value equals the default for a key
|
|
---@param key string State key
|
|
---@param value any Value to check
|
|
---@return boolean isDefault True if value equals default
|
|
local function isDefaultValue(key, value)
|
|
local defaultVal = stateDefaults[key]
|
|
|
|
-- If no default defined, check for common defaults
|
|
if defaultVal == nil then
|
|
-- Empty tables are default
|
|
if type(value) == "table" and next(value) == nil then
|
|
return true
|
|
end
|
|
-- nil values are default
|
|
if value == nil then
|
|
return true
|
|
end
|
|
-- Otherwise, not a default value
|
|
return false
|
|
end
|
|
|
|
-- Compare values
|
|
if type(value) == "table" then
|
|
-- Empty tables are considered default
|
|
if next(value) == nil then
|
|
return true
|
|
end
|
|
-- For other tables, compare contents (shallow)
|
|
if type(defaultVal) ~= "table" then
|
|
return false
|
|
end
|
|
for k, v in pairs(value) do
|
|
if defaultVal[k] ~= v then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
else
|
|
return value == defaultVal
|
|
end
|
|
end
|
|
|
|
-- ====================
|
|
-- ID Generation
|
|
-- ====================
|
|
|
|
--- 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 = {
|
|
onEvent = true,
|
|
parent = true,
|
|
children = true,
|
|
onFocus = true,
|
|
onBlur = true,
|
|
onTextInput = true,
|
|
onTextChange = true,
|
|
onEnter = true,
|
|
userdata = true,
|
|
-- Dynamic input/state properties that should not affect ID stability
|
|
text = true, -- Text content changes as user types
|
|
placeholder = true, -- Placeholder text is presentational
|
|
editable = true, -- Editable state can be toggled dynamically
|
|
selectOnFocus = true, -- Input behavior flag
|
|
autoGrow = true, -- Auto-grow behavior flag
|
|
passwordMode = true, -- Password mode can be toggled
|
|
}
|
|
|
|
-- 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
|
|
|
|
--- Generate a unique ID from call site and properties
|
|
---@param props table|nil Optional properties to include in ID generation
|
|
---@param parent table|nil Optional parent element for tree-based ID generation
|
|
---@return string
|
|
function StateManager.generateID(props, parent)
|
|
-- 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 base location key from source file and line number
|
|
local filename = source:match("([^/\\]+)$") or source -- Get filename
|
|
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
|
|
local locationKey = filename .. "_L" .. line
|
|
|
|
-- If we have a parent, use tree-based ID generation for stability
|
|
if parent and parent.id and parent.id ~= "" then
|
|
-- For child elements, use call-site (file + line) like top-level elements
|
|
-- This ensures the same call site always generates the same ID, even when
|
|
-- retained children persist in parent.children array
|
|
local baseID = parent.id .. "_" .. locationKey
|
|
|
|
-- Count how many children have been created at THIS call site
|
|
local callSiteKey = parent.id .. "_" .. locationKey
|
|
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
|
|
local instanceNum = callSiteCounters[callSiteKey]
|
|
|
|
if instanceNum > 1 then
|
|
baseID = baseID .. "_" .. instanceNum
|
|
end
|
|
|
|
-- Add property hash if provided (for additional differentiation)
|
|
-- IMPORTANT: Skip property hash for retained-mode elements to ensure ID stability
|
|
-- Retained elements should persist across frames even if props change slightly
|
|
if props and props.mode ~= "retained" 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
|
|
|
|
-- No parent (top-level element): use call-site counter approach
|
|
-- Track how many elements have been created at this location
|
|
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
|
local instanceNum = callSiteCounters[locationKey]
|
|
|
|
local 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)
|
|
-- IMPORTANT: Skip property hash for retained-mode elements to ensure ID stability
|
|
-- Retained elements should persist across frames even if props change slightly
|
|
if props and props.mode ~= "retained" 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
|
|
-- Lazy load ErrorHandler
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
ErrorHandler.error("StateManager", "SYS_001", {
|
|
parameter = "id",
|
|
value = "nil",
|
|
})
|
|
end
|
|
|
|
-- Create state if it doesn't exist
|
|
if not stateStore[id] then
|
|
-- Start with empty state (sparse storage)
|
|
stateStore[id] = defaultState or {}
|
|
|
|
-- Create metadata
|
|
stateMetadata[id] = {
|
|
lastFrame = frameNumber,
|
|
createdFrame = frameNumber,
|
|
accessCount = 0,
|
|
}
|
|
else
|
|
-- Update metadata
|
|
local meta = stateMetadata[id]
|
|
meta.lastFrame = frameNumber
|
|
meta.accessCount = meta.accessCount + 1
|
|
end
|
|
|
|
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
|
|
-- Lazy load ErrorHandler
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
ErrorHandler.error("StateManager", "SYS_001", {
|
|
parameter = "id",
|
|
value = "nil",
|
|
})
|
|
end
|
|
|
|
-- Create sparse state (remove default values)
|
|
local sparseState = {}
|
|
for key, value in pairs(state) do
|
|
if not isDefaultValue(key, value) then
|
|
sparseState[key] = value
|
|
end
|
|
end
|
|
|
|
stateStore[id] = sparseState
|
|
|
|
-- Update or create metadata
|
|
if not stateMetadata[id] then
|
|
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 newState table New state values to merge
|
|
function StateManager.updateState(id, newState)
|
|
local state = StateManager.getState(id)
|
|
|
|
-- Merge new state into existing state (with diffing optimization)
|
|
local changed = false
|
|
for key, value in pairs(newState) do
|
|
if state[key] ~= value then
|
|
state[key] = value
|
|
changed = true
|
|
end
|
|
end
|
|
|
|
-- Only update metadata if something actually changed
|
|
if changed then
|
|
stateMetadata[id].lastFrame = frameNumber
|
|
end
|
|
end
|
|
|
|
--- Update state only if values have changed (optimized for immediate mode)
|
|
---@param id string Element ID
|
|
---@param newState table New state values to merge
|
|
---@return boolean changed True if any values changed
|
|
function StateManager.updateStateIfChanged(id, newState)
|
|
local state = StateManager.getState(id)
|
|
local changed = false
|
|
|
|
for key, value in pairs(newState) do
|
|
-- Skip if value hasn't changed (optimization)
|
|
if state[key] ~= value then
|
|
state[key] = value
|
|
changed = true
|
|
end
|
|
end
|
|
|
|
if changed then
|
|
stateMetadata[id].lastFrame = frameNumber
|
|
end
|
|
|
|
return changed
|
|
end
|
|
|
|
--- Clear state for a specific element ID
|
|
---@param id string Element ID
|
|
function StateManager.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 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)
|
|
function StateManager.incrementFrame()
|
|
frameNumber = frameNumber + 1
|
|
-- Reset call site counters for new frame
|
|
callSiteCounters = {}
|
|
end
|
|
|
|
--- Get current frame number
|
|
---@return number
|
|
function StateManager.getFrameNumber()
|
|
return frameNumber
|
|
end
|
|
|
|
-- ====================
|
|
-- Cleanup & Maintenance
|
|
-- ====================
|
|
|
|
--- Clean up stale states (not accessed recently)
|
|
---@return number count Number of states cleaned up
|
|
function StateManager.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
|
|
|
|
-- Clean up empty states (sparse storage optimization)
|
|
for id, state in pairs(stateStore) do
|
|
if next(state) == nil then
|
|
stateStore[id] = nil
|
|
stateMetadata[id] = nil
|
|
cleanedCount = cleanedCount + 1
|
|
end
|
|
end
|
|
|
|
return cleanedCount
|
|
end
|
|
|
|
--- Force cleanup if state count exceeds maximum
|
|
---@return number count Number of states cleaned up
|
|
function StateManager.forceCleanupIfNeeded()
|
|
local stateCount = StateManager.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 StateManager.getStateCount()
|
|
local count = 0
|
|
for _ in pairs(stateStore) do
|
|
count = count + 1
|
|
end
|
|
return count
|
|
end
|
|
|
|
--- Clear all states
|
|
function StateManager.clearAllStates()
|
|
stateStore = {}
|
|
stateMetadata = {}
|
|
end
|
|
|
|
--- Configure state management
|
|
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
|
function StateManager.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 table stats State usage statistics
|
|
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
|
|
|
|
-- Count callSiteCounters
|
|
local callSiteCount = 0
|
|
for _ in pairs(callSiteCounters) do
|
|
callSiteCount = callSiteCount + 1
|
|
end
|
|
|
|
-- Warn if callSiteCounters is unexpectedly large
|
|
if callSiteCount > 1000 then
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("StateManager", "STATE_001", {
|
|
count = callSiteCount,
|
|
expected = "near 0",
|
|
frameNumber = frameNumber,
|
|
})
|
|
end
|
|
end
|
|
|
|
return {
|
|
stateCount = stateCount,
|
|
frameNumber = frameNumber,
|
|
oldestState = oldest,
|
|
newestState = newest,
|
|
callSiteCounterCount = callSiteCount,
|
|
}
|
|
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
|
|
|
|
--- Get internal state (for debugging/profiling only)
|
|
---@return table internal {stateStore, stateMetadata, callSiteCounters}
|
|
function StateManager._getInternalState()
|
|
return {
|
|
stateStore = stateStore,
|
|
stateMetadata = stateMetadata,
|
|
callSiteCounters = callSiteCounters,
|
|
}
|
|
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
|
|
---@return table state State object for the element
|
|
function StateManager.getCurrentState(id)
|
|
return stateStore[id] or {}
|
|
end
|
|
|
|
--- Get the active state values for an element (interaction states only)
|
|
---@param id string Element ID
|
|
---@return table state Active state values
|
|
function StateManager.getActiveState(id)
|
|
local state = StateManager.getState(id)
|
|
|
|
-- Return only the active state properties (not tracking frames or internal state)
|
|
return {
|
|
hover = state.hover,
|
|
pressed = state.pressed,
|
|
focused = state.focused,
|
|
disabled = state.disabled,
|
|
active = state.active,
|
|
scrollbarHoveredVertical = state.scrollbarHoveredVertical,
|
|
scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal,
|
|
scrollbarDragging = state.scrollbarDragging,
|
|
hoveredScrollbar = state.hoveredScrollbar,
|
|
scrollbarDragOffset = state.scrollbarDragOffset,
|
|
}
|
|
end
|
|
|
|
--- Check if an element is currently hovered
|
|
---@param id string Element ID
|
|
---@return boolean
|
|
function StateManager.isHovered(id)
|
|
local state = StateManager.getState(id)
|
|
return state.hover or false
|
|
end
|
|
|
|
--- Check if an element is currently pressed
|
|
---@param id string Element ID
|
|
---@return boolean
|
|
function StateManager.isPressed(id)
|
|
local state = StateManager.getState(id)
|
|
return state.pressed or false
|
|
end
|
|
|
|
--- Check if an element is currently focused
|
|
---@param id string Element ID
|
|
---@return boolean
|
|
function StateManager.isFocused(id)
|
|
local state = StateManager.getState(id)
|
|
return state.focused or false
|
|
end
|
|
|
|
--- Check if an element is disabled
|
|
---@param id string Element ID
|
|
---@return boolean
|
|
function StateManager.isDisabled(id)
|
|
local state = StateManager.getState(id)
|
|
return state.disabled or false
|
|
end
|
|
|
|
--- Check if an element is active (e.g., input focused)
|
|
---@param id string Element ID
|
|
---@return boolean
|
|
function StateManager.isActive(id)
|
|
local state = StateManager.getState(id)
|
|
return state.active or false
|
|
end
|
|
|
|
-- ====================
|
|
-- Retained Children Management (for mixed-mode trees)
|
|
-- ====================
|
|
|
|
--- Save retained children for an element
|
|
--- Only stores children that are in retained mode
|
|
---@param id string Parent element ID
|
|
---@param children table Array of child elements
|
|
function StateManager.saveRetainedChildren(id, children)
|
|
if not id or not children then
|
|
return
|
|
end
|
|
|
|
-- Filter to only retained-mode children
|
|
local retainedChildren = {}
|
|
for _, child in ipairs(children) do
|
|
if child._elementMode == "retained" then
|
|
table.insert(retainedChildren, child)
|
|
end
|
|
end
|
|
|
|
-- Only save if we have retained children
|
|
if #retainedChildren > 0 then
|
|
local state = StateManager.getState(id)
|
|
state.retainedChildren = retainedChildren
|
|
end
|
|
end
|
|
|
|
--- Get retained children for an element
|
|
--- Returns an array of retained-mode child elements
|
|
---@param id string Parent element ID
|
|
---@return table children Array of retained child elements (empty if none)
|
|
function StateManager.getRetainedChildren(id)
|
|
if not id then
|
|
return {}
|
|
end
|
|
|
|
local state = StateManager.getCurrentState(id)
|
|
if state.retainedChildren then
|
|
-- Verify children still exist (weren't destroyed)
|
|
local validChildren = {}
|
|
for _, child in ipairs(state.retainedChildren) do
|
|
-- Children are element objects, check if they're still valid
|
|
-- A destroyed element would have nil references or be garbage collected
|
|
if child and type(child) == "table" and child.id then
|
|
table.insert(validChildren, child)
|
|
end
|
|
end
|
|
return validChildren
|
|
end
|
|
|
|
return {}
|
|
end
|
|
|
|
--- Clear retained children for an element
|
|
--- Used when parent is destroyed or children are manually removed
|
|
---@param id string Parent element ID
|
|
function StateManager.clearRetainedChildren(id)
|
|
if not id then
|
|
return
|
|
end
|
|
|
|
local state = StateManager.getCurrentState(id)
|
|
if state.retainedChildren then
|
|
state.retainedChildren = nil
|
|
end
|
|
end
|
|
|
|
return StateManager
|