diff --git a/.gitignore b/.gitignore index bae1536..c2df842 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ lurker.lua themes/metal/ themes/space/ .DS_STORE -tasks +#tasks diff --git a/modules/Element.lua b/modules/Element.lua index c15daf4..9baa018 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -2,25 +2,23 @@ -- Element Object -- ==================== --- Module dependencies (using relative paths) -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - -local GuiState = req("GuiState") -local Theme = req("Theme") -local Color = req("Color") -local Units = req("Units") -local Blur = req("Blur") -local ImageRenderer = req("ImageRenderer") -local NineSlice = req("NineSlice") -local RoundedRect = req("RoundedRect") ---local Animation = req("Animation") -local ImageCache = req("ImageCache") -local utils = req("utils") -local Grid = req("Grid") -local InputEvent = req("InputEvent") +-- Module dependencies +local GuiState = require("GuiState") +local Theme = require("Theme") +local Color = require("Color") +local Units = require("Units") +local Blur = require("Blur") +local ImageRenderer = require("ImageRenderer") +local NineSlice = require("NineSlice") +local RoundedRect = require("RoundedRect") +--local Animation = require("Animation") +local ImageCache = require("ImageCache") +local utils = require("utils") +local Grid = require("Grid") +local InputEvent = require("InputEvent") +local StateManager = require("StateManager") +local StateManager = req("StateManager") +local StateManager = req("StateManager") -- Extract utilities local enums = utils.enums @@ -128,6 +126,7 @@ Public API methods to access internal state: ---@field theme string? -- Theme component to use for rendering ---@field themeComponent string? ---@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled) +---@field _stateId string? -- State manager ID for this element ---@field disabled boolean? -- Whether the element is disabled (default: false) ---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) ---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) diff --git a/modules/StateManager.lua b/modules/StateManager.lua new file mode 100644 index 0000000..bb34ee3 --- /dev/null +++ b/modules/StateManager.lua @@ -0,0 +1,306 @@ +-- ==================== +-- 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 \ No newline at end of file