diff --git a/FlexLove.lua b/FlexLove.lua index 294d30c..9d020ff 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -430,54 +430,14 @@ function flexlove.endFrame() end end - -- Save state back for all elements created this frame (with diffing optimization) + -- Save state for all elements created this frame + -- State is collected from element and all sub-modules via element:saveState() + -- This is the ONLY place state is saved in immediate mode for _, element in ipairs(flexlove._currentFrameElements) do if element.id and element.id ~= "" then - -- Build state update object - local stateUpdate = {} - - -- Get event handler state - if element._eventHandler then - local eventState = element._eventHandler:getState() - for k, v in pairs(eventState) do - stateUpdate[k] = v - end - end - - stateUpdate._focused = element._focused - stateUpdate._cursorPosition = element._cursorPosition - stateUpdate._selectionStart = element._selectionStart - stateUpdate._selectionEnd = element._selectionEnd - stateUpdate._textBuffer = element._textBuffer - stateUpdate._scrollX = element._scrollX - stateUpdate._scrollY = element._scrollY - stateUpdate._scrollbarDragging = element._scrollbarDragging - stateUpdate._hoveredScrollbar = element._hoveredScrollbar - stateUpdate._scrollbarDragOffset = element._scrollbarDragOffset - -- Cursor blink state is stored in TextEditor instance - if element._textEditor then - stateUpdate._cursorBlinkTimer = element._textEditor._cursorBlinkTimer - stateUpdate._cursorVisible = element._textEditor._cursorVisible - stateUpdate._cursorBlinkPaused = element._textEditor._cursorBlinkPaused - stateUpdate._cursorBlinkPauseTimer = element._textEditor._cursorBlinkPauseTimer - end + -- Collect state from element and all sub-modules + local stateUpdate = element:saveState() - -- Track blur-related properties for cache invalidation - if element.backdropBlur or element.contentBlur then - stateUpdate._blurX = element.x - stateUpdate._blurY = element.y - stateUpdate._blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - stateUpdate._blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - if element.backdropBlur then - stateUpdate._backdropBlurIntensity = element.backdropBlur.intensity - stateUpdate._backdropBlurQuality = element.backdropBlur.quality - end - if element.contentBlur then - stateUpdate._contentBlurIntensity = element.contentBlur.intensity - stateUpdate._contentBlurQuality = element.contentBlur.quality - end - end - -- Use optimized update that only changes modified values -- Returns true if state was changed (meaning blur cache needs invalidation) local stateChanged = StateManager.updateStateIfChanged(element.id, stateUpdate) @@ -757,22 +717,8 @@ function flexlove.update(dt) flexlove._activeEventElement = nil - -- In immediate mode, save state after update so that cursor blink timer changes persist - if flexlove._immediateMode and flexlove._currentFrameElements then - for _, element in ipairs(flexlove._currentFrameElements) do - if element.id and element.id ~= "" and element.editable and element._focused and element._textEditor then - local state = StateManager.getState(element.id, {}) - - -- Save cursor blink state (updated during element:update()) - state._cursorBlinkTimer = element._textEditor._cursorBlinkTimer - state._cursorVisible = element._textEditor._cursorVisible - state._cursorBlinkPaused = element._textEditor._cursorBlinkPaused - state._cursorBlinkPauseTimer = element._textEditor._cursorBlinkPauseTimer - - StateManager.setState(element.id, state) - end - end - end + -- Note: State saving happens in endFrame() after element:update() is called + -- This ensures all state changes (including cursor blink) are captured once per frame end --- Internal GC management function (called from update) @@ -1065,69 +1011,35 @@ function flexlove.new(props) -- Inject scroll state into props BEFORE creating element -- This ensures scroll position is set before layoutChildren/detectOverflow is called - props._scrollX = state._scrollX or 0 - props._scrollY = state._scrollY or 0 + -- ScrollManager state uses _scrollX/_scrollY with underscore prefix + if state.scrollManager then + props._scrollX = state.scrollManager._scrollX or 0 + props._scrollY = state.scrollManager._scrollY or 0 + else + -- Fallback to old state structure for backward compatibility + props._scrollX = state._scrollX or 0 + props._scrollY = state._scrollY or 0 + end local element = Element.new(props) - -- Bind persistent state to element (ImmediateModeState) - -- Restore event handler state - if element._eventHandler then - element._eventHandler:setState(state) - end - element._focused = state._focused - element._focused = state._focused - element._cursorPosition = state._cursorPosition - element._selectionStart = state._selectionStart - element._selectionEnd = state._selectionEnd - element._textBuffer = state._textBuffer or element.text or "" - -- Note: scroll position already set from props during Element.new() - -- element._scrollX and element._scrollY already restored - element._scrollbarDragging = state._scrollbarDragging ~= nil and state._scrollbarDragging or false - element._hoveredScrollbar = state._hoveredScrollbar - element._scrollbarDragOffset = state._scrollbarDragOffset ~= nil and state._scrollbarDragOffset or 0 - - -- Sync scrollbar drag state to ScrollManager if it exists - if element._scrollManager then - element._scrollManager._scrollbarDragging = element._scrollbarDragging - element._scrollManager._hoveredScrollbar = element._hoveredScrollbar - element._scrollManager._scrollbarDragOffset = element._scrollbarDragOffset - end - - -- Restore cursor blink state (will be restored by TextEditor:restoreState() if element has _textEditor) - -- These are kept for backward compatibility but are no longer used directly on element - element._cursorBlinkTimer = state._cursorBlinkTimer or 0 - element._cursorVisible = state._cursorVisible - element._cursorBlinkPaused = state._cursorBlinkPaused or false - element._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer or 0 + -- Restore all state from StateManager (delegates to sub-modules) + element:restoreState(state) -- 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 (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 - - -- Sync interactive scroll state to ScrollManager if it exists - if element._scrollManager then - element._scrollManager._scrollbarHoveredVertical = element._scrollbarHoveredVertical or false - element._scrollManager._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal or false - end - -- 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 state.disabled then + local eventState = state.eventHandler or {} + if element.disabled or eventState.disabled then element._themeState = "disabled" - elseif element.active or state.active then + elseif element.active or eventState.active then element._themeState = "active" - elseif state.pressed then + elseif eventState._pressed and next(eventState._pressed) then element._themeState = "pressed" - elseif state.hover then + elseif eventState._hovered then element._themeState = "hover" else element._themeState = "normal" diff --git a/modules/Element.lua b/modules/Element.lua index 33f7173..5ba6789 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -3160,6 +3160,110 @@ function Element:setProperty(property, value) end end +-- ==================== +-- State Persistence +-- ==================== + +--- Save all element state for immediate mode persistence +--- Collects state from all sub-modules and returns consolidated state +---@return ElementStateData state Complete state snapshot +function Element:saveState() + local state = {} + + -- Element-owned state + state._focused = self._focused + + -- EventHandler state (if exists) + if self._eventHandler then + state.eventHandler = self._eventHandler:getState() + end + + -- TextEditor state (if exists) + if self._textEditor then + state.textEditor = self._textEditor:getState() + end + + -- ScrollManager state (if exists) + if self._scrollManager then + state.scrollManager = self._scrollManager:getState() + end + + -- Blur cache data (for cache invalidation) + if self.backdropBlur or self.contentBlur then + state.blur = { + _blurX = self.x, + _blurY = self.y, + _blurWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right), + _blurHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom), + } + + if self.backdropBlur then + state.blur._backdropBlurIntensity = self.backdropBlur.intensity + state.blur._backdropBlurQuality = self.backdropBlur.quality + end + + if self.contentBlur then + state.blur._contentBlurIntensity = self.contentBlur.intensity + state.blur._contentBlurQuality = self.contentBlur.quality + end + end + + return state +end + +--- Restore all element state from StateManager +--- Distributes state to all sub-modules +---@param state ElementStateData State to restore +function Element:restoreState(state) + if not state then + return + end + + -- Restore element-owned state + if state._focused ~= nil then + self._focused = state._focused + end + + -- Restore EventHandler state (if exists) + if self._eventHandler and state.eventHandler then + self._eventHandler:setState(state.eventHandler) + end + + -- Restore TextEditor state (if exists) + if self._textEditor and state.textEditor then + self._textEditor:setState(state.textEditor) + end + + -- Restore ScrollManager state (if exists) + if self._scrollManager and state.scrollManager then + self._scrollManager:setState(state.scrollManager) + end + + -- Note: Blur cache data is used for invalidation, not restoration +end + +--- Check if blur cache should be invalidated based on state changes +---@param oldState ElementStateData? Previous state +---@param newState ElementStateData Current state +---@return boolean shouldInvalidate True if blur cache should be cleared +function Element:shouldInvalidateBlurCache(oldState, newState) + if not oldState or not oldState.blur or not newState.blur then + return false + end + + local old = oldState.blur + local new = newState.blur + + -- Check if any blur-related property changed + return old._blurX ~= new._blurX + or old._blurY ~= new._blurY + or old._blurWidth ~= new._blurWidth + or old._blurHeight ~= new._blurHeight + or old._backdropBlurIntensity ~= new._backdropBlurIntensity + or old._backdropBlurQuality ~= new._backdropBlurQuality + or old._contentBlurIntensity ~= new._contentBlurIntensity + or old._contentBlurQuality ~= new._contentBlurQuality +end --- Cleanup method to break circular references (for immediate mode) --- Note: Cleans internal module state but keeps structure for inspection diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index cffa427..ee6710c 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -617,11 +617,13 @@ end ---@return table State data function ScrollManager:getState() return { - scrollX = self._scrollX, - scrollY = self._scrollY, - scrollbarDragging = self._scrollbarDragging, - hoveredScrollbar = self._hoveredScrollbar, - scrollbarDragOffset = self._scrollbarDragOffset, + _scrollX = self._scrollX or 0, + _scrollY = self._scrollY or 0, + _scrollbarDragging = self._scrollbarDragging or false, + _hoveredScrollbar = self._hoveredScrollbar, + _scrollbarDragOffset = self._scrollbarDragOffset or 0, + _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, + _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, } end @@ -632,21 +634,44 @@ function ScrollManager:setState(state) return end - if state.scrollX then + -- Support both old (scrollX) and new (_scrollX) field names for backward compatibility + if state._scrollX ~= nil then + self._scrollX = state._scrollX + elseif state.scrollX ~= nil then self._scrollX = state.scrollX end - if state.scrollY then + + if state._scrollY ~= nil then + self._scrollY = state._scrollY + elseif state.scrollY ~= nil then self._scrollY = state.scrollY end - if state.scrollbarDragging ~= nil then + + if state._scrollbarDragging ~= nil then + self._scrollbarDragging = state._scrollbarDragging + elseif state.scrollbarDragging ~= nil then self._scrollbarDragging = state.scrollbarDragging end - if state.hoveredScrollbar then + + if state._hoveredScrollbar ~= nil then + self._hoveredScrollbar = state._hoveredScrollbar + elseif state.hoveredScrollbar ~= nil then self._hoveredScrollbar = state.hoveredScrollbar end - if state.scrollbarDragOffset then + + if state._scrollbarDragOffset ~= nil then + self._scrollbarDragOffset = state._scrollbarDragOffset + elseif state.scrollbarDragOffset ~= nil then self._scrollbarDragOffset = state.scrollbarDragOffset end + + if state._scrollbarHoveredVertical ~= nil then + self._scrollbarHoveredVertical = state._scrollbarHoveredVertical + end + + if state._scrollbarHoveredHorizontal ~= nil then + self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal + end end --- Handle touch press for scrolling diff --git a/modules/TextEditor.lua b/modules/TextEditor.lua index 1c420f2..6ce9093 100644 --- a/modules/TextEditor.lua +++ b/modules/TextEditor.lua @@ -1681,6 +1681,66 @@ function TextEditor:_getFont(element) return element._renderer:getFont(element) end +--- Get current state for persistence +---@return table state TextEditor state snapshot +function TextEditor:getState() + return { + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + _textBuffer = self._textBuffer, + _cursorBlinkTimer = self._cursorBlinkTimer, + _cursorVisible = self._cursorVisible, + _cursorBlinkPaused = self._cursorBlinkPaused, + _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, + _focused = self._focused, + } +end + +--- Restore state from persistence +---@param state table State to restore +function TextEditor:setState(state) + if not state then + return + end + + if state._cursorPosition ~= nil then + self._cursorPosition = state._cursorPosition + end + + if state._selectionStart ~= nil then + self._selectionStart = state._selectionStart + end + + if state._selectionEnd ~= nil then + self._selectionEnd = state._selectionEnd + end + + if state._textBuffer ~= nil then + self._textBuffer = state._textBuffer + end + + if state._cursorBlinkTimer ~= nil then + self._cursorBlinkTimer = state._cursorBlinkTimer + end + + if state._cursorVisible ~= nil then + self._cursorVisible = state._cursorVisible + end + + if state._cursorBlinkPaused ~= nil then + self._cursorBlinkPaused = state._cursorBlinkPaused + end + + if state._cursorBlinkPauseTimer ~= nil then + self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer + end + + if state._focused ~= nil then + self._focused = state._focused + end +end + ---Save state to StateManager (for immediate mode) ---@param element Element? The parent element function TextEditor:_saveState(element) diff --git a/modules/types.lua b/modules/types.lua index 86c7a7c..dce8183 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -187,3 +187,23 @@ local TransformProps ---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200) ---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true) local FlexLoveConfig = {} + +--=====================================-- +-- For State Persistence +--=====================================-- +---@class ElementStateData +---@field _focused boolean? +---@field eventHandler table? -- EventHandler state +---@field textEditor table? -- TextEditor state +---@field scrollManager table? -- ScrollManager state +---@field blur BlurCacheData? -- Blur cache invalidation data + +---@class BlurCacheData +---@field _blurX number +---@field _blurY number +---@field _blurWidth number +---@field _blurHeight number +---@field _backdropBlurIntensity number? +---@field _backdropBlurQuality string? +---@field _contentBlurIntensity number? +---@field _contentBlurQuality string? diff --git a/testing/__tests__/scroll_manager_test.lua b/testing/__tests__/scroll_manager_test.lua index ee2af64..8de719b 100644 --- a/testing/__tests__/scroll_manager_test.lua +++ b/testing/__tests__/scroll_manager_test.lua @@ -915,11 +915,11 @@ function TestScrollManagerEdgeCases:testGetState() sm._scrollbarDragOffset = 10 local state = sm:getState() - luaunit.assertEquals(state.scrollX, 50) - luaunit.assertEquals(state.scrollY, 75) - luaunit.assertTrue(state.scrollbarDragging) - luaunit.assertEquals(state.hoveredScrollbar, "vertical") - luaunit.assertEquals(state.scrollbarDragOffset, 10) + luaunit.assertEquals(state._scrollX, 50) + luaunit.assertEquals(state._scrollY, 75) + luaunit.assertTrue(state._scrollbarDragging) + luaunit.assertEquals(state._hoveredScrollbar, "vertical") + luaunit.assertEquals(state._scrollbarDragOffset, 10) end function TestScrollManagerEdgeCases:testSetStateWithNil()