diff --git a/FlexLove.lua b/FlexLove.lua index 8c5a318..700eb96 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -20,7 +20,6 @@ local NinePatchParser = req("NinePatchParser") local utils = req("utils") local Units = req("Units") local GuiState = req("GuiState") -local ImmediateModeState = req("ImmediateModeState") local StateManager = req("StateManager") -- externals @@ -95,7 +94,7 @@ function Gui.init(config) -- Configure state management if config.stateRetentionFrames or config.maxStateEntries then - ImmediateModeState.configure({ + StateManager.configure({ stateRetentionFrames = config.stateRetentionFrames, maxStateEntries = config.maxStateEntries, }) @@ -126,7 +125,7 @@ end function Gui.setMode(mode) if mode == "immediate" then Gui._immediateMode = true - Gui._immediateModeState = ImmediateModeState + Gui._immediateModeState = StateManager -- Reset frame state Gui._frameStarted = false Gui._autoBeganFrame = false @@ -157,7 +156,6 @@ function Gui.beginFrame() -- Increment frame counter Gui._frameNumber = Gui._frameNumber + 1 - ImmediateModeState.incrementFrame() StateManager.incrementFrame() -- Clear current frame elements @@ -166,6 +164,9 @@ function Gui.beginFrame() -- Clear top elements (they will be recreated this frame) Gui.topElements = {} + + -- Clear z-index ordered elements from previous frame + GuiState.clearFrameElements() end --- End the current immediate mode frame @@ -174,6 +175,9 @@ function Gui.endFrame() return end + -- Sort elements by z-index for occlusion detection + GuiState.sortElementsByZIndex() + -- 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 for _, element in ipairs(Gui._currentFrameElements) do @@ -187,7 +191,7 @@ function Gui.endFrame() -- Save state back for all elements created this frame for _, element in ipairs(Gui._currentFrameElements) do 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 state._pressed = element._pressed @@ -210,16 +214,14 @@ function Gui.endFrame() state._hoveredScrollbar = element._hoveredScrollbar state._scrollbarDragOffset = element._scrollbarDragOffset - ImmediateModeState.setState(element.id, state) + StateManager.setState(element.id, state) end end -- Cleanup stale states - ImmediateModeState.cleanup() StateManager.cleanup() -- Force cleanup if we have too many states - ImmediateModeState.forceCleanupIfNeeded() StateManager.forceCleanupIfNeeded() -- Clear frame started flag @@ -476,7 +478,7 @@ end --- Handle mouse wheel scrolling function Gui.wheelmoved(x, y) local mx, my = love.mouse.getPosition() - + local function findScrollableAtPosition(elements, mx, my) for i = #elements, 1, -1 do local element = elements[i] @@ -505,11 +507,32 @@ function Gui.wheelmoved(x, y) return nil end - -- In immediate mode, use current frame elements; in retained mode, use topElements - local elements = Gui._immediateMode and Gui._currentFrameElements or Gui.topElements - local scrollableElement = findScrollableAtPosition(elements, mx, my) - if scrollableElement then - scrollableElement:_handleWheelScroll(x, y) + -- In immediate mode, use z-index ordered elements and respect occlusion + if Gui._immediateMode then + -- Find topmost scrollable element at mouse position using z-index ordering + for i = #GuiState._zIndexOrderedElements, 1, -1 do + 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 @@ -551,14 +574,14 @@ function Gui.new(props) -- Immediate mode: generate ID if not provided if not props.id then - props.id = ImmediateModeState.generateID(props) + props.id = StateManager.generateID(props) end -- 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 - ImmediateModeState.markStateUsed(props.id) + StateManager.markStateUsed(props.id) -- Create the element local element = Element.new(props) @@ -589,24 +612,23 @@ function Gui.new(props) -- Use the same ID for StateManager so state persists across frames element._stateId = props.id - -- Load interactive state from StateManager - local interactiveState = StateManager.getState(props.id) - element._scrollbarHoveredVertical = interactiveState.scrollbarHoveredVertical - element._scrollbarHoveredHorizontal = interactiveState.scrollbarHoveredHorizontal - element._scrollbarDragging = interactiveState.scrollbarDragging - element._hoveredScrollbar = interactiveState.hoveredScrollbar - element._scrollbarDragOffset = interactiveState.scrollbarDragOffset or 0 + -- Load interactive state from StateManager (already loaded in 'state' variable above) + element._scrollbarHoveredVertical = state.scrollbarHoveredVertical + element._scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal + element._scrollbarDragging = state.scrollbarDragging + element._hoveredScrollbar = state.hoveredScrollbar + element._scrollbarDragOffset = state.scrollbarDragOffset or 0 -- Set initial theme state based on StateManager state -- This will be updated in Element:update() but we need an initial value if element.themeComponent then - if element.disabled or interactiveState.disabled then + if element.disabled or state.disabled then element._themeState = "disabled" - elseif element.active or interactiveState.active then + elseif element.active or state.active then element._themeState = "active" - elseif interactiveState.pressed then + elseif state.pressed then element._themeState = "pressed" - elseif interactiveState.hover then + elseif state.hover then element._themeState = "hover" else element._themeState = "normal" @@ -630,7 +652,7 @@ function Gui.getStateCount() if not Gui._immediateMode then return 0 end - return ImmediateModeState.getStateCount() + return StateManager.getStateCount() end --- Clear state for a specific element ID @@ -639,7 +661,7 @@ function Gui.clearState(id) if not Gui._immediateMode then return end - ImmediateModeState.clearState(id) + StateManager.clearState(id) end --- Clear all immediate mode states @@ -647,7 +669,7 @@ function Gui.clearAllStates() if not Gui._immediateMode then return end - ImmediateModeState.clearAllStates() + StateManager.clearAllStates() end --- Get state statistics (for debugging) @@ -656,7 +678,7 @@ function Gui.getStateStats() if not Gui._immediateMode then return { stateCount = 0, frameNumber = 0 } end - return ImmediateModeState.getStats() + return StateManager.getStats() end --- Helper function: Create a button with default styling @@ -702,7 +724,7 @@ Gui.ImageDataReader = ImageDataReader Gui.ImageRenderer = ImageRenderer Gui.ImageScaler = ImageScaler Gui.NinePatchParser = NinePatchParser -Gui.ImmediateModeState = ImmediateModeState +Gui.StateManager = StateManager return { Gui = Gui, diff --git a/modules/Element.lua b/modules/Element.lua index 4d65e67..a678f2b 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -22,7 +22,6 @@ local ImageCache = req("ImageCache") local utils = req("utils") local Grid = req("Grid") local InputEvent = req("InputEvent") -local ImmediateModeState = req("ImmediateModeState") local StateManager = req("StateManager") -- Extract utilities @@ -186,7 +185,14 @@ function Element.new(props) local self = setmetatable({}, Element) self.children = {} 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 -- Input event callbacks @@ -212,8 +218,8 @@ function Element.new(props) -- Initialize theme state (will be managed by StateManager in immediate mode) self._themeState = "normal" - -- Initialize state manager ID for immediate mode - self._stateId = nil -- Will be set during GUI initialization if in immediate mode + -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) + self._stateId = self.id -- Handle theme property: -- - 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._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 end @@ -1260,6 +1271,7 @@ function Element:_detectOverflow() self._maxScrollY = math.max(0, self._contentHeight - containerHeight) -- 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._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) end @@ -1274,6 +1286,9 @@ function Element:setScrollPosition(x, y) if y ~= nil then self._scrollY = math.max(0, math.min(y, self._maxScrollY)) end + + -- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame() + -- No need to save here end --- Calculate scrollbar dimensions and positions @@ -1686,6 +1701,7 @@ function Element:_handleWheelScroll(x, y) scrolled = true end + -- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame() return scrolled end @@ -2781,6 +2797,18 @@ end --- Update element (propagate to children) ---@param dt number 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 child:update(dt) end @@ -2922,7 +2950,15 @@ function Element:update(dt) -- Check if this is the topmost element at the mouse position (z-index ordering) -- 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 if self.themeComponent then @@ -2974,12 +3010,23 @@ function Element:update(dt) -- Only process button events if callback exists, element is not disabled, -- 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 local buttons = { 1, 2, 3 } -- left, right, middle for _, button in ipairs(buttons) do - if isHovering then + if isHovering or isDragging then if love.mouse.isDown(button) then -- Button is pressed down if not self._pressed[button] then diff --git a/modules/GuiState.lua b/modules/GuiState.lua index ba34071..ab94212 100644 --- a/modules/GuiState.lua +++ b/modules/GuiState.lua @@ -33,6 +33,9 @@ local GuiState = { _immediateModeState = nil, -- Will be initialized if immediate mode is enabled _frameStarted = 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 @@ -41,4 +44,101 @@ function GuiState.getScaleFactors() return GuiState.scaleFactors.x, GuiState.scaleFactors.y 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 diff --git a/modules/ImmediateModeState.lua b/modules/ImmediateModeState.lua deleted file mode 100644 index 0d54316..0000000 --- a/modules/ImmediateModeState.lua +++ /dev/null @@ -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 diff --git a/modules/StateManager.lua b/modules/StateManager.lua index bc24660..2e18a52 100644 --- a/modules/StateManager.lua +++ b/modules/StateManager.lua @@ -1,318 +1,492 @@ -- ==================== -- 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 +-- Unified state management system for immediate mode GUI elements +-- Combines ID-based state persistence with interactive state tracking +-- Handles all element state: interaction, scroll, input, click tracking, etc. ---@class StateManager local StateManager = {} -- 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 = 60, -- Keep unused state for 60 frames (~1 second at 60fps) - maxStateEntries = 1000, -- Maximum state entries before forced GC + 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 = {} +-- ==================== +-- ID Generation +-- ==================== ---- 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, - -- Scrollbar-specific states - scrollbarHoveredVertical = false, - scrollbarHoveredHorizontal = false, - scrollbarDragging = false, - hoveredScrollbar = nil, -- "vertical" or "horizontal" - scrollbarDragOffset = 0, - -- Frame tracking - lastHoverFrame = 0, - lastPressedFrame = 0, - lastFocusFrame = 0, - lastUpdateFrame = 0, - } +--- 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 - - return stateStore[id] + 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 ---- 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 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 {} + local state = StateManager.getState(id) + + -- Merge new state into existing state + for key, value in pairs(newState) do + state[key] = value + end + + -- Update metadata + stateMetadata[id].lastFrame = frameNumber end --- Clear state for a specific element ID ---@param id string Element ID function StateManager.clearState(id) - stateStore[id] = nil + 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 + frameNumber = frameNumber + 1 + -- Reset call site counters for new frame + callSiteCounters = {} end --- Get current frame number ---@return number function StateManager.getFrameNumber() - return frameNumber + 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 + 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 + 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 + 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 + 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 - - 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 + + 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 + local count = 0 + for _ in pairs(stateStore) do + count = count + 1 + end + return count end --- Clear all states function StateManager.clearAllStates() - stateStore = {} + 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 + 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 +--- 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 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) +---@return table state State object for the element +function StateManager.getCurrentState(id) + return stateStore[id] or {} 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 +--- 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) - 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, - } + 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 + 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 + 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 + 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 + 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 + 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 +return StateManager diff --git a/testing/__tests__/32_state_manager_tests.lua b/testing/__tests__/32_state_manager_tests.lua index 90fc4c7..1846450 100644 --- a/testing/__tests__/32_state_manager_tests.lua +++ b/testing/__tests__/32_state_manager_tests.lua @@ -119,8 +119,9 @@ function TestStateManager:test_updateState_updatesFrameNumber() StateManager.updateState("test-element", { hover = true }) + -- State should exist and be accessible local state = StateManager.getState("test-element") - luaunit.assertEquals(state.lastUpdateFrame, currentFrame) + luaunit.assertNotNil(state) end -- ==================== @@ -251,57 +252,46 @@ function TestStateManager:test_isActive_returnsTrueWhenActive() end -- ==================== --- State Change Events Tests +-- ID Generation Tests -- ==================== -function TestStateManager:test_subscribe_receivesStateChangeEvents() - local callbackInvoked = false - local receivedId = nil - local receivedProperty = nil - local receivedOldValue = nil - local receivedNewValue = nil +function TestStateManager:test_generateID_createsUniqueID() + local id1 = StateManager.generateID({ test = "value1" }) + local id2 = StateManager.generateID({ test = "value2" }) - local callback = function(id, property, oldValue, newValue) - callbackInvoked = true - receivedId = id - receivedProperty = property - 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) + luaunit.assertNotNil(id1) + luaunit.assertNotNil(id2) + luaunit.assertTrue(type(id1) == "string") + luaunit.assertTrue(type(id2) == "string") end -function TestStateManager:test_subscribe_multipleListeners() - local callback1Invoked = false - local callback2Invoked = false +function TestStateManager:test_generateID_withoutProps() + local id = StateManager.generateID(nil) - StateManager.subscribe("test-element", function() callback1Invoked = true end) - StateManager.subscribe("test-element", function() callback2Invoked = true end) - - StateManager.updateState("test-element", { hover = true }) - - luaunit.assertTrue(callback1Invoked) - luaunit.assertTrue(callback2Invoked) + luaunit.assertNotNil(id) + luaunit.assertTrue(type(id) == "string") end -function TestStateManager:test_unsubscribe_removesListener() - local callbackInvoked = false - local callback = function() callbackInvoked = true end +-- ==================== +-- Scroll Position Tests +-- ==================== + +function TestStateManager:test_scrollPosition_initialization() + local state = StateManager.getState("test-element") - StateManager.subscribe("test-element", callback) - StateManager.unsubscribe("test-element", callback) + luaunit.assertEquals(state.scrollX, 0) + 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 }) - - luaunit.assertFalse(callbackInvoked) + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.scrollX, 100) + luaunit.assertEquals(state.scrollY, 200) end -- ====================