Files
FlexLove/modules/StateManager.lua
Michael Freno 619e1a7393 showing tasks
2025-11-05 14:58:28 -05:00

306 lines
8.7 KiB
Lua

-- ====================
-- State Manager Module
-- ====================
-- Provides centralized state management for immediate mode GUI elements
-- Handles hover, pressed, disabled, and other interactive states properly
-- Manages state change events and integrates with theme components
---@class StateManager
local StateManager = {}
-- State storage: ID -> state table
local stateStore = {}
local frameNumber = 0
-- Configuration
local config = {
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps)
maxStateEntries = 1000, -- Maximum state entries before forced GC
}
-- State change listeners
local stateChangeListeners = {}
--- Get or create a state object for an element ID
---@param id string Element ID
---@return table state State object for the element
function StateManager.getState(id)
if not stateStore[id] then
stateStore[id] = {
hover = false,
pressed = false,
focused = false,
disabled = false,
active = false,
lastHoverFrame = 0,
lastPressedFrame = 0,
lastFocusFrame = 0,
lastUpdateFrame = 0,
}
end
return stateStore[id]
end
--- Update state for an element ID
---@param id string Element ID
---@param newState table New state values to merge
function StateManager.updateState(id, newState)
local state = StateManager.getState(id)
-- Track which properties are changing
local changedProperties = {}
for key, value in pairs(newState) do
if state[key] ~= value then
changedProperties[key] = true
end
state[key] = value
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
--- Clear state for a specific element ID
---@param id string Element ID
function StateManager.clearState(id)
stateStore[id] = nil
end
--- Increment frame counter (called at frame start)
function StateManager.incrementFrame()
frameNumber = frameNumber + 1
end
--- Get current frame number
---@return number
function StateManager.getFrameNumber()
return frameNumber
end
--- 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, state in pairs(stateStore) do
-- Check if state is old (no updates in last N frames)
local lastUpdateFrame = math.max(
state.lastHoverFrame,
state.lastPressedFrame,
state.lastFocusFrame,
state.lastUpdateFrame
)
if frameNumber - lastUpdateFrame > retentionFrames then
stateStore[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 = 0
for _ in pairs(stateStore) do
stateCount = stateCount + 1
end
if stateCount > config.maxStateEntries then
-- Clean up states not accessed in last 10 frames (aggressive)
local cleanedCount = 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
--- 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 = {}
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
--- Subscribe to state change events for an element ID
---@param id string Element ID
---@param callback fun(id: string, property: string, oldValue: any, newValue: any)
function StateManager.subscribe(id, callback)
if not stateChangeListeners[id] then
stateChangeListeners[id] = {}
end
table.insert(stateChangeListeners[id], callback)
end
--- Notify listeners of a state change
---@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
---@return table state Active state values
function StateManager.getActiveState(id)
local state = StateManager.getState(id)
-- Return only the active state properties (not tracking frames)
return {
hover = state.hover,
pressed = state.pressed,
focused = state.focused,
disabled = state.disabled,
active = state.active,
}
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
--- Get the time since last hover event for an element
---@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