From c77d93fdee4df71acc8f59468050805c5bdd98d9 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 5 Nov 2025 16:39:43 -0500 Subject: [PATCH] almost --- FlexLove.lua | 44 ++- modules/Element.lua | 105 ++++-- modules/ImmediateModeState.lua | 21 +- modules/StateManager.lua | 12 + testing/__tests__/32_state_manager_tests.lua | 332 +++++++++++++++++++ testing/runAll.lua | 1 + 6 files changed, 489 insertions(+), 26 deletions(-) create mode 100644 testing/__tests__/32_state_manager_tests.lua diff --git a/FlexLove.lua b/FlexLove.lua index 523e5e9..9f60454 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -21,6 +21,7 @@ local utils = req("utils") local Units = req("Units") local GuiState = req("GuiState") local ImmediateModeState = req("ImmediateModeState") +local StateManager = req("StateManager") -- externals ---@type Theme @@ -131,6 +132,7 @@ function Gui.beginFrame() -- Increment frame counter Gui._frameNumber = Gui._frameNumber + 1 ImmediateModeState.incrementFrame() + StateManager.incrementFrame() -- Clear current frame elements Gui._currentFrameElements = {} @@ -167,6 +169,9 @@ function Gui.endFrame() state._textBuffer = element._textBuffer state._scrollX = element._scrollX state._scrollY = element._scrollY + state._scrollbarDragging = element._scrollbarDragging + state._hoveredScrollbar = element._hoveredScrollbar + state._scrollbarDragOffset = element._scrollbarDragOffset ImmediateModeState.setState(element.id, state) end @@ -174,9 +179,11 @@ function Gui.endFrame() -- Cleanup stale states ImmediateModeState.cleanup() + StateManager.cleanup() -- Force cleanup if we have too many states ImmediateModeState.forceCleanupIfNeeded() + StateManager.forceCleanupIfNeeded() end -- Canvas cache for game rendering @@ -442,7 +449,9 @@ function Gui.wheelmoved(x, y) return nil end - local scrollableElement = findScrollableAtPosition(Gui.topElements, mx, my) + -- 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) end @@ -492,7 +501,7 @@ function Gui.new(props) -- Create the element local element = Element.new(props) - -- Bind persistent state to element + -- Bind persistent state to element (ImmediateModeState) -- Copy stateful properties from persistent state element._pressed = state._pressed or {} element._lastClickTime = state._lastClickTime @@ -510,6 +519,37 @@ function Gui.new(props) element._textBuffer = state._textBuffer or element.text or "" element._scrollX = state._scrollX or element._scrollX or 0 element._scrollY = state._scrollY or element._scrollY or 0 + element._scrollbarDragging = state._scrollbarDragging or false + element._hoveredScrollbar = state._hoveredScrollbar + element._scrollbarDragOffset = state._scrollbarDragOffset or 0 + + -- Bind element to StateManager for interactive states + -- 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 + + -- 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 + element._themeState = "disabled" + elseif element.active or interactiveState.active then + element._themeState = "active" + elseif interactiveState.pressed then + element._themeState = "pressed" + elseif interactiveState.hover then + element._themeState = "hover" + else + element._themeState = "normal" + end + end -- Store element in current frame tracking table.insert(Gui._currentFrameElements, element) diff --git a/modules/Element.lua b/modules/Element.lua index 9baa018..4d65e67 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -2,22 +2,27 @@ -- Element Object -- ==================== +-- Setup module path for relative requires +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + -- 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 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") +local ImmediateModeState = req("ImmediateModeState") local StateManager = req("StateManager") -- Extract utilities @@ -204,8 +209,11 @@ function Element.new(props) self._lastMouseX = {} -- Track last mouse X position per button self._lastMouseY = {} -- Track last mouse Y position per button - -- Initialize theme + -- 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 -- Handle theme property: -- - theme: which theme to use (defaults to Gui.defaultTheme if not specified) @@ -1513,6 +1521,15 @@ function Element:_handleScrollbarPress(mouseX, mouseY, button) local thumbX = trackX + dims.horizontal.thumbX self._scrollbarDragOffset = mouseX - thumbX end + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = self._scrollbarDragging, + hoveredScrollbar = self._hoveredScrollbar, + scrollbarDragOffset = self._scrollbarDragOffset, + }) + end return true -- Event consumed elseif scrollbar.region == "track" then @@ -1582,6 +1599,14 @@ function Element:_handleScrollbarRelease(button) if self._scrollbarDragging then self._scrollbarDragging = false + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = false, + }) + end + return true end @@ -2814,6 +2839,16 @@ function Element:update(dt) if not scrollbar and not self._scrollbarDragging then self._hoveredScrollbar = nil end + + -- Update scrollbar state in StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarHoveredVertical = self._scrollbarHoveredVertical, + scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal, + scrollbarDragging = self._scrollbarDragging, + hoveredScrollbar = self._hoveredScrollbar, + }) + end -- Handle scrollbar dragging if self._scrollbarDragging and love.mouse.isDown(1) then @@ -2821,6 +2856,13 @@ function Element:update(dt) elseif self._scrollbarDragging then -- Mouse button released self._scrollbarDragging = false + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = false, + }) + end end -- Handle scrollbar click/press (independent of callback) @@ -2884,12 +2926,14 @@ function Element:update(dt) -- Update theme state based on interaction if self.themeComponent then + local newThemeState = "normal" + -- Disabled state takes priority if self.disabled then - self._themeState = "disabled" + newThemeState = "disabled" -- Active state (for inputs when focused/typing) elseif self.active then - self._themeState = "active" + newThemeState = "active" -- Only show hover/pressed states if this element is active (not blocked) elseif isHovering and isActiveElement then -- Check if any button is pressed @@ -2902,13 +2946,30 @@ function Element:update(dt) end if anyPressed then - self._themeState = "pressed" + newThemeState = "pressed" else - self._themeState = "hover" + newThemeState = "hover" end - else - self._themeState = "normal" end + + -- Update state (in StateManager if in immediate mode, otherwise locally) + if self._stateId and Gui._immediateMode then + -- Update in StateManager for immediate mode + local hover = newThemeState == "hover" + local pressed = newThemeState == "pressed" + local focused = newThemeState == "active" or self._focused + + StateManager.updateState(self._stateId, { + hover = hover, + pressed = pressed, + focused = focused, + disabled = self.disabled, + active = self.active, + }) + end + + -- Always update local state for backward compatibility + self._themeState = newThemeState end -- Only process button events if callback exists, element is not disabled, diff --git a/modules/ImmediateModeState.lua b/modules/ImmediateModeState.lua index d7c9784..0d54316 100644 --- a/modules/ImmediateModeState.lua +++ b/modules/ImmediateModeState.lua @@ -84,6 +84,9 @@ local function hashProps(props, visited, depth) 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 @@ -102,9 +105,20 @@ function ImmediateModeState.generateID(props) -- Create ID from source file and line number local baseID = source:match("([^/\\]+)$") or source -- Get filename baseID = baseID:gsub("%.lua$", "") -- Remove .lua extension - baseID = baseID .. "_L" .. line + 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 + -- Add property hash if provided (for additional differentiation) if props then local propHash = hashProps(props) if propHash ~= "" then @@ -197,6 +211,8 @@ 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 @@ -320,6 +336,7 @@ function ImmediateModeState.reset() stateStore = {} stateMetadata = {} frameNumber = 0 + callSiteCounters = {} end return ImmediateModeState diff --git a/modules/StateManager.lua b/modules/StateManager.lua index bb34ee3..bc24660 100644 --- a/modules/StateManager.lua +++ b/modules/StateManager.lua @@ -32,6 +32,13 @@ function StateManager.getState(id) 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, @@ -244,6 +251,11 @@ function StateManager.getActiveState(id) 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 diff --git a/testing/__tests__/32_state_manager_tests.lua b/testing/__tests__/32_state_manager_tests.lua new file mode 100644 index 0000000..90fc4c7 --- /dev/null +++ b/testing/__tests__/32_state_manager_tests.lua @@ -0,0 +1,332 @@ +-- ==================== +-- StateManager Module Tests +-- ==================== + +local luaunit = require("testing.luaunit") +require("testing.loveStub") -- Required to mock LOVE functions +local StateManager = require("modules.StateManager") + +TestStateManager = {} + +function TestStateManager:setUp() + -- Reset StateManager before each test + StateManager.clearAllStates() +end + +function TestStateManager:tearDown() + -- Clean up after each test + StateManager.clearAllStates() +end + +-- ==================== +-- Basic State Operations +-- ==================== + +function TestStateManager:test_getState_createsNewState() + local state = StateManager.getState("test-element") + + luaunit.assertNotNil(state) + luaunit.assertEquals(state.hover, false) + luaunit.assertEquals(state.pressed, false) + luaunit.assertEquals(state.focused, false) + luaunit.assertEquals(state.disabled, false) + luaunit.assertEquals(state.active, false) +end + +function TestStateManager:test_getState_returnsExistingState() + local state1 = StateManager.getState("test-element") + state1.hover = true + + local state2 = StateManager.getState("test-element") + + luaunit.assertEquals(state2.hover, true) + luaunit.assertTrue(state1 == state2) -- Same reference +end + +function TestStateManager:test_updateState_modifiesState() + StateManager.updateState("test-element", { + hover = true, + pressed = false, + }) + + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.hover, true) + luaunit.assertEquals(state.pressed, false) +end + +function TestStateManager:test_updateState_mergesPartialState() + StateManager.updateState("test-element", { hover = true }) + StateManager.updateState("test-element", { pressed = true }) + + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.hover, true) + luaunit.assertEquals(state.pressed, true) +end + +function TestStateManager:test_clearState_removesState() + StateManager.updateState("test-element", { hover = true }) + StateManager.clearState("test-element") + + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.hover, false) -- New state created with defaults +end + +-- ==================== +-- Scrollbar State Tests +-- ==================== + +function TestStateManager:test_scrollbarStates_initialization() + local state = StateManager.getState("test-element") + + luaunit.assertEquals(state.scrollbarHoveredVertical, false) + luaunit.assertEquals(state.scrollbarHoveredHorizontal, false) + luaunit.assertEquals(state.scrollbarDragging, false) + luaunit.assertNil(state.hoveredScrollbar) + luaunit.assertEquals(state.scrollbarDragOffset, 0) +end + +function TestStateManager:test_scrollbarStates_updates() + StateManager.updateState("test-element", { + scrollbarHoveredVertical = true, + scrollbarDragging = true, + hoveredScrollbar = "vertical", + scrollbarDragOffset = 25, + }) + + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.scrollbarHoveredVertical, true) + luaunit.assertEquals(state.scrollbarDragging, true) + luaunit.assertEquals(state.hoveredScrollbar, "vertical") + luaunit.assertEquals(state.scrollbarDragOffset, 25) +end + +-- ==================== +-- Frame Management Tests +-- ==================== + +function TestStateManager:test_frameNumber_increments() + local frame1 = StateManager.getFrameNumber() + StateManager.incrementFrame() + local frame2 = StateManager.getFrameNumber() + + luaunit.assertEquals(frame2, frame1 + 1) +end + +function TestStateManager:test_updateState_updatesFrameNumber() + StateManager.incrementFrame() + StateManager.incrementFrame() + local currentFrame = StateManager.getFrameNumber() + + StateManager.updateState("test-element", { hover = true }) + + local state = StateManager.getState("test-element") + luaunit.assertEquals(state.lastUpdateFrame, currentFrame) +end + +-- ==================== +-- Cleanup Tests +-- ==================== + +function TestStateManager:test_cleanup_removesStaleStates() + -- Configure short retention + StateManager.configure({ stateRetentionFrames = 5 }) + + -- Create state + StateManager.updateState("test-element", { hover = true }) + + -- Advance frames beyond retention + for i = 1, 10 do + StateManager.incrementFrame() + end + + -- Cleanup should remove the state + local cleanedCount = StateManager.cleanup() + luaunit.assertEquals(cleanedCount, 1) + + -- Reset config + StateManager.configure({ stateRetentionFrames = 60 }) +end + +function TestStateManager:test_cleanup_keepsActiveStates() + StateManager.configure({ stateRetentionFrames = 5 }) + + StateManager.updateState("test-element", { hover = true }) + + -- Update state within retention period + for i = 1, 3 do + StateManager.incrementFrame() + StateManager.updateState("test-element", { hover = true }) + end + + local cleanedCount = StateManager.cleanup() + luaunit.assertEquals(cleanedCount, 0) -- Should not clean active state + + StateManager.configure({ stateRetentionFrames = 60 }) +end + +function TestStateManager:test_forceCleanupIfNeeded_activatesWhenOverLimit() + StateManager.configure({ maxStateEntries = 5 }) + + -- Create more states than limit + for i = 1, 10 do + StateManager.updateState("element-" .. i, { hover = true }) + end + + -- Advance frames + for i = 1, 15 do + StateManager.incrementFrame() + end + + local cleanedCount = StateManager.forceCleanupIfNeeded() + luaunit.assertTrue(cleanedCount > 0) + + StateManager.configure({ maxStateEntries = 1000 }) +end + +-- ==================== +-- State Count Tests +-- ==================== + +function TestStateManager:test_getStateCount_returnsCorrectCount() + luaunit.assertEquals(StateManager.getStateCount(), 0) + + StateManager.getState("element-1") + StateManager.getState("element-2") + StateManager.getState("element-3") + + luaunit.assertEquals(StateManager.getStateCount(), 3) +end + +-- ==================== +-- Active State Tests +-- ==================== + +function TestStateManager:test_getActiveState_returnsOnlyActiveProperties() + StateManager.updateState("test-element", { + hover = true, + pressed = false, + focused = true, + }) + + local activeState = StateManager.getActiveState("test-element") + + luaunit.assertEquals(activeState.hover, true) + luaunit.assertEquals(activeState.pressed, false) + luaunit.assertEquals(activeState.focused, true) + luaunit.assertNil(activeState.lastUpdateFrame) -- Should not include frame tracking +end + +-- ==================== +-- Helper Function Tests +-- ==================== + +function TestStateManager:test_isHovered_returnsTrueWhenHovered() + StateManager.updateState("test-element", { hover = true }) + luaunit.assertTrue(StateManager.isHovered("test-element")) +end + +function TestStateManager:test_isHovered_returnsFalseWhenNotHovered() + StateManager.updateState("test-element", { hover = false }) + luaunit.assertFalse(StateManager.isHovered("test-element")) +end + +function TestStateManager:test_isPressed_returnsTrueWhenPressed() + StateManager.updateState("test-element", { pressed = true }) + luaunit.assertTrue(StateManager.isPressed("test-element")) +end + +function TestStateManager:test_isFocused_returnsTrueWhenFocused() + StateManager.updateState("test-element", { focused = true }) + luaunit.assertTrue(StateManager.isFocused("test-element")) +end + +function TestStateManager:test_isDisabled_returnsTrueWhenDisabled() + StateManager.updateState("test-element", { disabled = true }) + luaunit.assertTrue(StateManager.isDisabled("test-element")) +end + +function TestStateManager:test_isActive_returnsTrueWhenActive() + StateManager.updateState("test-element", { active = true }) + luaunit.assertTrue(StateManager.isActive("test-element")) +end + +-- ==================== +-- State Change Events Tests +-- ==================== + +function TestStateManager:test_subscribe_receivesStateChangeEvents() + local callbackInvoked = false + local receivedId = nil + local receivedProperty = nil + local receivedOldValue = nil + local receivedNewValue = nil + + 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) +end + +function TestStateManager:test_subscribe_multipleListeners() + local callback1Invoked = false + local callback2Invoked = false + + 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) +end + +function TestStateManager:test_unsubscribe_removesListener() + local callbackInvoked = false + local callback = function() callbackInvoked = true end + + StateManager.subscribe("test-element", callback) + StateManager.unsubscribe("test-element", callback) + + StateManager.updateState("test-element", { hover = true }) + + luaunit.assertFalse(callbackInvoked) +end + +-- ==================== +-- Configuration Tests +-- ==================== + +function TestStateManager:test_configure_updatesSettings() + StateManager.configure({ + stateRetentionFrames = 30, + maxStateEntries = 500, + }) + + -- Test that configuration was applied by creating many states + -- and checking cleanup behavior (indirect test) + for i = 1, 10 do + StateManager.updateState("element-" .. i, { hover = true }) + end + + luaunit.assertEquals(StateManager.getStateCount(), 10) + + -- Reset to defaults + StateManager.configure({ + stateRetentionFrames = 60, + maxStateEntries = 1000, + }) +end + +return TestStateManager diff --git a/testing/runAll.lua b/testing/runAll.lua index 308a17b..058d1cd 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -35,6 +35,7 @@ local testFiles = { "testing/__tests__/29_drag_event_tests.lua", "testing/__tests__/30_scrollbar_features_tests.lua", "testing/__tests__/31_immediate_mode_basic_tests.lua", + "testing/__tests__/32_state_manager_tests.lua", } local success = true