From 0bceade7d50e9fb75eb9a20452fec20421d35de5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 11 Dec 2025 11:37:20 -0500 Subject: [PATCH] starting mode escape hatch --- FlexLove.lua | 43 +- README.md | 45 +- examples/mode_override_demo.lua | 275 ++++++++++ modules/Element.lua | 30 +- modules/types.lua | 1 + .../settings_menu_mode_profile.lua | 507 ++++++++++++++++++ .../__tests__/element_mode_override_test.lua | 343 ++++++++++++ testing/__tests__/mixed_mode_events_test.lua | 192 +++++++ 8 files changed, 1420 insertions(+), 16 deletions(-) create mode 100644 examples/mode_override_demo.lua create mode 100644 profiling/__profiles__/settings_menu_mode_profile.lua create mode 100644 testing/__tests__/element_mode_override_test.lua create mode 100644 testing/__tests__/mixed_mode_events_test.lua diff --git a/FlexLove.lua b/FlexLove.lua index da81481..b722caf 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -434,17 +434,45 @@ function flexlove.endFrame() -- Layout all top-level elements now that all children have been added -- This ensures overflow detection happens with complete child lists + -- Only process immediate-mode elements (retained elements handle their own layout) for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then + if not element.parent and element._elementMode == "immediate" then element:layoutChildren() -- Layout with all children present end end + + -- Handle mixed-mode trees: if immediate-mode children were added to retained-mode parents, + -- trigger layout on those parents so the children are properly positioned + -- We check for parents with _childrenDirty flag OR parents with immediate-mode children + local retainedParentsToLayout = {} + for _, element in ipairs(flexlove._currentFrameElements) do + if element._elementMode == "immediate" and element.parent and element.parent._elementMode == "retained" then + -- Found immediate child with retained parent - mark parent for layout + retainedParentsToLayout[element.parent] = true + end + end + + -- Layout all retained parents that had immediate children added + for parent, _ in pairs(retainedParentsToLayout) do + parent:layoutChildren() + end -- Auto-update all top-level elements created this frame -- This happens AFTER layout so positions are correct -- Use accumulated dt from FlexLove.update() calls to properly update animations and cursor blink + -- Process immediate-mode top-level elements (they recursively update their children) for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then + if not element.parent and element._elementMode == "immediate" then + element:update(flexlove._accumulatedDt) + end + end + + -- Also update immediate-mode children that have retained-mode parents + -- These won't be updated by the loop above (since they have parents) + -- And their retained parents won't auto-update (retained = manual lifecycle) + -- So we need to explicitly update them here + for _, element in ipairs(flexlove._currentFrameElements) do + if element.parent and element.parent._elementMode == "retained" and element._elementMode == "immediate" then element:update(flexlove._accumulatedDt) end end @@ -452,8 +480,9 @@ function flexlove.endFrame() -- 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 + -- Only process immediate-mode elements (retained elements don't use StateManager) for _, element in ipairs(flexlove._currentFrameElements) do - if element.id and element.id ~= "" then + if element._elementMode == "immediate" and element.id and element.id ~= "" then -- Collect state from element and all sub-modules local stateUpdate = element:saveState() @@ -1010,11 +1039,15 @@ end function flexlove.new(props) props = props or {} - -- If not in immediate mode, use standard Element.new - if not flexlove._immediateMode then + -- Determine effective mode: props.mode takes precedence over global mode + local effectiveMode = props.mode or (flexlove._immediateMode and "immediate" or "retained") + + -- If element is in retained mode, use standard Element.new + if effectiveMode == "retained" then return Element.new(props) end + -- Element is in immediate mode - proceed with immediate-mode logic -- Auto-begin frame if not manually started (convenience feature) if not flexlove._frameStarted then flexlove.beginFrame() diff --git a/README.md b/README.md index 79966c2..62f9eaf 100644 --- a/README.md +++ b/README.md @@ -197,8 +197,46 @@ local button2 = FlexLove.new({ ``` -You should be able to mix both modes in the same application - use retained mode for your main UI and immediate mode for debug overlays or dynamic elements, -though this hasn't been tested. +#### Per-Element Mode Override + +You can override the rendering mode on a per-element basis using the `mode` property. This allows you to mix immediate and retained mode elements in the same application: + +```lua +-- Initialize in immediate mode globally +FlexLove.init({ immediateMode = true }) + +function love.draw() + FlexLove.beginFrame() + + -- This button uses immediate mode (follows global setting) + local dynamicButton = FlexLove.new({ + text = "Frame: " .. love.timer.getTime(), + onEvent = function() print("Dynamic!") end + }) + + -- This panel uses retained mode (override with mode prop) + -- It will persist and won't be recreated each frame + local staticPanel = FlexLove.new({ + mode = "retained", -- Explicit override + width = "30vw", + height = "40vh", + backgroundColor = Color.new(0.2, 0.2, 0.2, 1), + -- No ID auto-generation since it's in retained mode + }) + + FlexLove.endFrame() +end +``` + +**Key behaviors:** +- `mode = "immediate"` - Element uses immediate-mode lifecycle (recreated each frame, auto-generates ID, uses StateManager) +- `mode = "retained"` - Element uses retained-mode lifecycle (persists across frames, no auto-ID, no StateManager) +- `mode = nil` - Element inherits from global mode setting (default behavior) +- Mode does NOT inherit from parent to child - each element independently controls its own lifecycle +- Common use cases: + - Performance-critical static UI in retained mode while using immediate mode globally + - Reactive debug overlays in immediate mode within a retained-mode application + - Mixed UI where some components are static (menus) and others are dynamic (HUD) ### Element Properties @@ -262,6 +300,9 @@ Common properties for all elements: disabled = false, disableHighlight = false, -- Disable pressed overlay (auto-true for themed elements) + -- Rendering Mode + mode = nil, -- "immediate", "retained", or nil (uses global setting) + -- Hierarchy parent = nil, -- Parent element } diff --git a/examples/mode_override_demo.lua b/examples/mode_override_demo.lua new file mode 100644 index 0000000..624b5b4 --- /dev/null +++ b/examples/mode_override_demo.lua @@ -0,0 +1,275 @@ +-- Mode Override Demo +-- Demonstrates per-element mode override in FlexLöve +-- Shows how to mix immediate and retained mode elements in the same application + +package.path = package.path .. ";../?.lua;../modules/?.lua" +local FlexLove = require("FlexLove") +local Color = require("modules.Color") + +-- Global state +local frameCount = 0 +local clickCount = 0 +local retainedPanelCreated = false +local retainedPanel = nil + +function love.load() + -- Initialize FlexLove in immediate mode globally + FlexLove.init({ + immediateMode = true, + theme = "space", + }) + + love.window.setTitle("Mode Override Demo - FlexLöve") + love.window.setMode(1200, 800) +end + +function love.update(dt) + FlexLove.update(dt) + frameCount = frameCount + 1 +end + +function love.draw() + love.graphics.clear(0.1, 0.1, 0.15, 1) + + FlexLove.beginFrame() + + -- Title - Immediate mode (default, recreated every frame) + FlexLove.new({ + text = "Mode Override Demo", + textSize = 32, + textColor = Color.new(1, 1, 1, 1), + width = "100vw", + height = 60, + textAlign = "center", + backgroundColor = Color.new(0.2, 0.2, 0.3, 1), + padding = { top = 15, bottom = 15 }, + }) + + -- Container for demo panels + local container = FlexLove.new({ + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "center", + alignItems = "flex-start", + gap = 20, + width = "100vw", + height = "calc(100vh - 60px)", + padding = { top = 20, left = 20, right = 20, bottom = 20 }, + }) + + -- LEFT PANEL: Immediate Mode (Dynamic, recreated every frame) + local leftPanel = FlexLove.new({ + mode = "immediate", -- Explicit immediate mode (would be default anyway) + parent = container, + width = "45vw", + height = "calc(100vh - 100px)", + backgroundColor = Color.new(0.15, 0.15, 0.2, 0.95), + cornerRadius = 8, + padding = { top = 20, left = 20, right = 20, bottom = 20 }, + positioning = "flex", + flexDirection = "vertical", + gap = 15, + }) + + -- Panel title + FlexLove.new({ + parent = leftPanel, + text = "Immediate Mode Panel", + textSize = 24, + textColor = Color.new(0.4, 0.8, 1, 1), + width = "100%", + height = 40, + }) + + -- Description + FlexLove.new({ + parent = leftPanel, + text = "Elements in this panel are recreated every frame.\nState is preserved by StateManager.", + textSize = 14, + textColor = Color.new(0.8, 0.8, 0.8, 1), + width = "100%", + height = 60, + }) + + -- Live frame counter (updates automatically) + FlexLove.new({ + parent = leftPanel, + text = string.format("Frame: %d", frameCount), + textSize = 18, + textColor = Color.new(1, 1, 0.5, 1), + width = "100%", + height = 30, + backgroundColor = Color.new(0.2, 0.2, 0.3, 1), + padding = { top = 5, left = 10, right = 10, bottom = 5 }, + cornerRadius = 4, + }) + + -- Click counter (updates automatically) + FlexLove.new({ + parent = leftPanel, + text = string.format("Clicks: %d", clickCount), + textSize = 18, + textColor = Color.new(0.5, 1, 0.5, 1), + width = "100%", + height = 30, + backgroundColor = Color.new(0.2, 0.2, 0.3, 1), + padding = { top = 5, left = 10, right = 10, bottom = 5 }, + cornerRadius = 4, + }) + + -- Interactive button (state preserved across frames) + FlexLove.new({ + parent = leftPanel, + text = "Click Me! (Immediate Mode)", + textSize = 16, + textColor = Color.new(1, 1, 1, 1), + width = "100%", + height = 50, + themeComponent = "button", + onEvent = function(element, event) + if event.type == "release" then + clickCount = clickCount + 1 + end + end, + }) + + -- Info box + FlexLove.new({ + parent = leftPanel, + text = "Notice how the frame counter updates\nautomatically without any manual updates.\n\nThis is the power of immediate mode:\nUI reflects application state automatically.", + textSize = 13, + textColor = Color.new(0.7, 0.7, 0.7, 1), + width = "100%", + height = "auto", + backgroundColor = Color.new(0.1, 0.1, 0.15, 1), + padding = { top = 10, left = 10, right = 10, bottom = 10 }, + cornerRadius = 4, + }) + + -- RIGHT PANEL: Retained Mode (Static, created once) + -- Only create on first frame + if not retainedPanelCreated then + retainedPanel = FlexLove.new({ + mode = "retained", -- Explicit retained mode override + parent = container, + width = "45vw", + height = "calc(100vh - 100px)", + backgroundColor = Color.new(0.2, 0.15, 0.15, 0.95), + cornerRadius = 8, + padding = { top = 20, left = 20, right = 20, bottom = 20 }, + positioning = "flex", + flexDirection = "vertical", + gap = 15, + }) + + -- Panel title (retained) + FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = "Retained Mode Panel", + textSize = 24, + textColor = Color.new(1, 0.6, 0.4, 1), + width = "100%", + height = 40, + }) + + -- Description (retained) + FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = "Elements in this panel are created once\nand persist across frames.", + textSize = 14, + textColor = Color.new(0.8, 0.8, 0.8, 1), + width = "100%", + height = 60, + }) + + -- Static frame counter (won't update) + local staticCounter = FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = string.format("Created at frame: %d", frameCount), + textSize = 18, + textColor = Color.new(1, 1, 0.5, 1), + width = "100%", + height = 30, + backgroundColor = Color.new(0.3, 0.2, 0.2, 1), + padding = { top = 5, left = 10, right = 10, bottom = 5 }, + cornerRadius = 4, + }) + + -- Click counter placeholder (must be manually updated) + local retainedClickCounter = FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = string.format("Clicks: %d (manual update needed)", clickCount), + textSize = 18, + textColor = Color.new(0.5, 1, 0.5, 1), + width = "100%", + height = 30, + backgroundColor = Color.new(0.3, 0.2, 0.2, 1), + padding = { top = 5, left = 10, right = 10, bottom = 5 }, + cornerRadius = 4, + }) + + -- Interactive button with manual update + FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = "Click Me! (Retained Mode)", + textSize = 16, + textColor = Color.new(1, 1, 1, 1), + width = "100%", + height = 50, + themeComponent = "button", + onEvent = function(element, event) + if event.type == "release" then + clickCount = clickCount + 1 + -- In retained mode, we must manually update the UI + retainedClickCounter.text = string.format("Clicks: %d (manual update needed)", clickCount) + end + end, + }) + + -- Info box (retained) + FlexLove.new({ + mode = "retained", + parent = retainedPanel, + text = "Notice how this panel's elements\ndon't update automatically.\n\nIn retained mode, you must manually\nupdate element properties when state changes.\n\nThis gives better performance for\nstatic UI elements.", + textSize = 13, + textColor = Color.new(0.7, 0.7, 0.7, 1), + width = "100%", + height = "auto", + backgroundColor = Color.new(0.15, 0.1, 0.1, 1), + padding = { top = 10, left = 10, right = 10, bottom = 10 }, + cornerRadius = 4, + }) + + retainedPanelCreated = true + end + + FlexLove.endFrame() + + -- Bottom instructions + love.graphics.setColor(0.5, 0.5, 0.5, 1) + love.graphics.print("Global mode: Immediate | Left panel: Immediate (explicit) | Right panel: Retained (override)", 10, love.graphics.getHeight() - 30) + love.graphics.print("Press ESC to quit", 10, love.graphics.getHeight() - 15) +end + +function love.keypressed(key) + if key == "escape" then + love.event.quit() + end +end + +function love.mousepressed(x, y, button) + FlexLove.mousepressed(x, y, button) +end + +function love.mousereleased(x, y, button) + FlexLove.mousereleased(x, y, button) +end + +function love.mousemoved(x, y, dx, dy) + FlexLove.mousemoved(x, y, dx, dy) +end diff --git a/modules/Element.lua b/modules/Element.lua index de45a01..475ad80 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -64,6 +64,7 @@ ---@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled) ---@field _themeManager ThemeManager -- Internal: theme manager instance ---@field _stateId string? -- State manager ID for this element +---@field _elementMode "immediate"|"retained" -- Lifecycle mode for this element (resolved from props.mode or global mode) ---@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) @@ -257,11 +258,22 @@ function Element.new(props) } end + -- Resolve element mode: props.mode takes precedence over global mode + -- This must happen BEFORE ID generation and state management + if props.mode == "immediate" then + self._elementMode = "immediate" + elseif props.mode == "retained" then + self._elementMode = "retained" + else + -- nil or invalid: use global mode + self._elementMode = Element._Context._immediateMode and "immediate" or "retained" + end + self.children = {} self.onEvent = props.onEvent -- Auto-generate ID in immediate mode if not provided - if Element._Context._immediateMode and (not props.id or props.id == "") then + if self._elementMode == "immediate" and (not props.id or props.id == "") then self.id = Element._StateManager.generateID(props, props.parent) else self.id = props.id or "" @@ -288,7 +300,7 @@ function Element.new(props) onEvent = self.onEvent, onEventDeferred = props.onEventDeferred, } - if Element._Context._immediateMode and self._stateId and self._stateId ~= "" then + if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then local state = Element._StateManager.getState(self._stateId) if state then -- Restore EventHandler state from StateManager (sparse storage - provide defaults) @@ -419,7 +431,7 @@ function Element.new(props) }, textEditorDeps) -- Restore TextEditor state from StateManager in immediate mode - if Element._Context._immediateMode and self._stateId and self._stateId ~= "" then + if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then local state = Element._StateManager.getState(self._stateId) if state and state.textEditor then -- Restore from nested textEditor state (saved via saveState()) @@ -1659,7 +1671,7 @@ function Element.new(props) self._scrollbarDragOffset = 0 -- Restore scrollbar state from StateManager in immediate mode (must happen before layout) - if Element._Context._immediateMode and self._stateId and self._stateId ~= "" then + if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then local state = Element._StateManager.getState(self._stateId) if state and state.scrollManager then -- Restore from nested scrollManager state (saved via saveState()) @@ -1688,7 +1700,7 @@ function Element.new(props) end -- Register element in z-index tracking for immediate mode - if Element._Context._immediateMode then + if self._elementMode == "immediate" then Element._Context.registerElement(self) end @@ -2413,7 +2425,7 @@ function Element:update(dt) end -- Restore scrollbar state from StateManager in immediate mode - if self._stateId and Element._Context._immediateMode then + if self._stateId and self._elementMode == "immediate" then local state = Element._StateManager.getState(self._stateId) if state and state.scrollManager then -- Restore from nested scrollManager state (saved via saveState()) @@ -2560,7 +2572,7 @@ function Element:update(dt) self:_syncScrollManagerState() end - if self._stateId and Element._Context._immediateMode then + if self._stateId and self._elementMode == "immediate" then Element._StateManager.updateState(self._stateId, { scrollbarDragging = false, }) @@ -2642,7 +2654,7 @@ function Element:update(dt) self._eventHandler:processMouseEvents(self, mx, my, isHovering, isActiveElement) -- In immediate mode, save EventHandler state to StateManager after processing events - if self._stateId and Element._Context._immediateMode and self._stateId ~= "" then + if self._stateId and self._elementMode == "immediate" and self._stateId ~= "" then local eventHandlerState = self._eventHandler:getState() Element._StateManager.updateState(self._stateId, { _pressed = eventHandlerState._pressed, @@ -2665,7 +2677,7 @@ function Element:update(dt) -- Update theme state via ThemeManager local newThemeState = self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled) - if self._stateId and Element._Context._immediateMode then + if self._stateId and self._elementMode == "immediate" then local hover = newThemeState == "hover" local pressed = newThemeState == "pressed" local focused = newThemeState == "active" or self._focused diff --git a/modules/types.lua b/modules/types.lua index 0b9b87f..7672548 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -33,6 +33,7 @@ local AnimationProps = {} --=====================================-- ---@class ElementProps ---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided) +---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default) ---@field parent Element? -- Parent element for hierarchical structure ---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) ---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0) diff --git a/profiling/__profiles__/settings_menu_mode_profile.lua b/profiling/__profiles__/settings_menu_mode_profile.lua new file mode 100644 index 0000000..8dd4514 --- /dev/null +++ b/profiling/__profiles__/settings_menu_mode_profile.lua @@ -0,0 +1,507 @@ +-- Profiling test comparing retained mode flag vs. default behavior in complex UI +-- This simulates creating a settings menu multiple times per frame to stress test +-- the performance difference between explicit mode="retained" and implicit retained mode + +package.path = package.path .. ";../../?.lua;../../modules/?.lua" + +local FlexLove = require("FlexLove") +local Color = require("modules.Color") + +-- Mock resolution sets (simplified) +local resolution_sets = { + ["16:9"] = { + { 1920, 1080 }, + { 1600, 900 }, + { 1280, 720 }, + }, + ["16:10"] = { + { 1920, 1200 }, + { 1680, 1050 }, + { 1280, 800 }, + }, +} + +-- Mock Settings object +local Settings = { + values = { + resolution = { width = 1920, height = 1080 }, + fullscreen = false, + vsync = true, + msaa = 4, + resizable = true, + borderless = false, + masterVolume = 0.8, + musicVolume = 0.7, + sfxVolume = 0.9, + crtEffectStrength = 0.3, + }, + get = function(self, key) + return self.values[key] + end, + set = function(self, key, value) + self.values[key] = value + end, + reset_to_defaults = function(self) end, + apply = function(self) end, +} + +-- Helper function to round numbers +local function round(num, decimals) + local mult = 10 ^ (decimals or 0) + return math.floor(num * mult + 0.5) / mult +end + +-- Simplified SettingsMenu implementation +local function create_settings_menu_with_mode_flag(use_mode_flag) + local GuiZIndexing = { MainMenuOverlay = 100 } + + -- Backdrop + local backdrop_props = { + z = GuiZIndexing.MainMenuOverlay - 1, + width = "100%", + height = "100%", + backdropBlur = { radius = 10 }, + backgroundColor = Color.new(1, 1, 1, 0.1), + } + if use_mode_flag then + backdrop_props.mode = "retained" + end + local backdrop = FlexLove.new(backdrop_props) + + -- Main window + local window_props = { + z = GuiZIndexing.MainMenuOverlay, + x = "5%", + y = "5%", + width = "90%", + height = "90%", + themeComponent = "framev3", + positioning = "flex", + flexDirection = "vertical", + justifySelf = "center", + justifyContent = "flex-start", + alignItems = "center", + scaleCorners = 3, + padding = { horizontal = "5%", vertical = "3%" }, + gap = 10, + } + if use_mode_flag then + window_props.mode = "retained" + end + local window = FlexLove.new(window_props) + + -- Close button + FlexLove.new({ + parent = window, + x = "2%", + y = "2%", + alignSelf = "flex-start", + themeComponent = "buttonv2", + width = "4vw", + height = "4vw", + text = "X", + textSize = "2xl", + textAlign = "center", + }) + + -- Title + FlexLove.new({ + parent = window, + text = "Settings", + textAlign = "center", + textSize = "3xl", + width = "100%", + margin = { top = "-4%", bottom = "4%" }, + }) + + -- Content container + local content = FlexLove.new({ + parent = window, + width = "100%", + height = "100%", + positioning = "flex", + flexDirection = "vertical", + padding = { top = "4%" }, + }) + + -- Display Settings Section + FlexLove.new({ + parent = content, + text = "Display Settings", + textAlign = "start", + textSize = "xl", + width = "100%", + textColor = Color.new(0.8, 0.9, 1, 1), + }) + + -- Resolution control + local row1 = FlexLove.new({ + parent = content, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = row1, + text = "Resolution", + textAlign = "start", + textSize = "md", + width = "30%", + }) + + local resolution = Settings:get("resolution") + FlexLove.new({ + parent = row1, + text = resolution.width .. " x " .. resolution.height, + themeComponent = "buttonv2", + width = "30%", + textAlign = "center", + textSize = "lg", + }) + + -- Fullscreen toggle + local row2 = FlexLove.new({ + parent = content, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = row2, + text = "Fullscreen", + textAlign = "start", + textSize = "md", + width = "60%", + }) + + local fullscreen = Settings:get("fullscreen") + FlexLove.new({ + parent = row2, + text = fullscreen and "ON" or "OFF", + themeComponent = fullscreen and "buttonv1" or "buttonv2", + textAlign = "center", + width = "15vw", + height = "4vh", + textSize = "md", + }) + + -- VSync toggle + local row3 = FlexLove.new({ + parent = content, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = row3, + text = "VSync", + textAlign = "start", + textSize = "md", + width = "60%", + }) + + local vsync = Settings:get("vsync") + FlexLove.new({ + parent = row3, + text = vsync and "ON" or "OFF", + themeComponent = vsync and "buttonv1" or "buttonv2", + textAlign = "center", + width = "15vw", + height = "4vh", + textSize = "md", + }) + + -- MSAA control + local row4 = FlexLove.new({ + parent = content, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = row4, + text = "MSAA", + textAlign = "start", + textSize = "md", + width = "30%", + }) + + local button_container = FlexLove.new({ + parent = row4, + width = "60%", + height = "100%", + positioning = "flex", + flexDirection = "horizontal", + gap = 5, + }) + + local msaa_values = { 0, 1, 2, 4, 8, 16 } + for _, msaa_val in ipairs(msaa_values) do + local is_selected = Settings:get("msaa") == msaa_val + FlexLove.new({ + parent = button_container, + themeComponent = is_selected and "buttonv1" or "buttonv2", + text = tostring(msaa_val), + textAlign = "center", + width = "8vw", + height = "100%", + textSize = "sm", + disabled = is_selected, + opacity = is_selected and 0.7 or 1.0, + }) + end + + -- Audio Settings Section + FlexLove.new({ + parent = content, + text = "Audio Settings", + textAlign = "start", + textSize = "xl", + width = "100%", + textColor = Color.new(0.8, 0.9, 1, 1), + }) + + -- Master volume slider + local row5 = FlexLove.new({ + parent = content, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = row5, + text = "Master Volume", + textAlign = "start", + textSize = "md", + width = "30%", + }) + + local slider_container = FlexLove.new({ + parent = row5, + width = "50%", + height = "100%", + positioning = "flex", + flexDirection = "horizontal", + alignItems = "center", + gap = 5, + }) + + local value = Settings:get("masterVolume") + local normalized = value + + local slider_track = FlexLove.new({ + parent = slider_container, + width = "80%", + height = "75%", + positioning = "flex", + flexDirection = "horizontal", + themeComponent = "framev3", + }) + + FlexLove.new({ + parent = slider_track, + width = (normalized * 100) .. "%", + height = "100%", + themeComponent = "buttonv1", + themeStateLock = true, + }) + + FlexLove.new({ + parent = slider_container, + text = string.format("%d", value * 100), + textAlign = "center", + textSize = "md", + width = "15%", + }) + + -- Meta controls (bottom buttons) + local meta_container = FlexLove.new({ + parent = window, + positioning = "absolute", + width = "100%", + height = "10%", + y = "90%", + x = "0%", + }) + + local button_bar = FlexLove.new({ + parent = meta_container, + width = "100%", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "center", + alignItems = "center", + gap = 10, + }) + + FlexLove.new({ + parent = button_bar, + themeComponent = "buttonv2", + text = "Reset", + textAlign = "center", + width = "15vw", + height = "6vh", + textSize = "lg", + }) + + return { backdrop = backdrop, window = window } +end + +-- Profile configuration +local PROFILE_NAME = "Settings Menu Mode Comparison" +local ITERATIONS_PER_TEST = 100 -- Create the menu 100 times to measure difference + +print("=" .. string.rep("=", 78)) +print(string.format(" %s", PROFILE_NAME)) +print("=" .. string.rep("=", 78)) +print() +print("This profile compares performance when creating a complex settings menu") +print("with explicit mode='retained' flags vs. implicit retained mode (global).") +print() +print(string.format("Test configuration:")) +print(string.format(" - Iterations: %d menu creations per test", ITERATIONS_PER_TEST)) +print(string.format(" - Elements per menu: ~45 (backdrop, window, buttons, sliders, etc.)")) +print(string.format(" - Total elements created: ~%d per test", ITERATIONS_PER_TEST * 45)) +print() + +-- Warm up +print("Warming up...") +FlexLove.init({ immediateMode = false, theme = "space" }) +for i = 1, 10 do + create_settings_menu_with_mode_flag(false) +end +collectgarbage("collect") + +-- Test 1: Without explicit mode flags (implicit retained via global setting) +print("Running Test 1: Without explicit mode='retained' flags...") +FlexLove.init({ immediateMode = false, theme = "space" }) +collectgarbage("collect") +local mem_before_implicit = collectgarbage("count") +local time_before_implicit = os.clock() + +for i = 1, ITERATIONS_PER_TEST do + local menu = create_settings_menu_with_mode_flag(false) +end + +local time_after_implicit = os.clock() +collectgarbage("collect") +local mem_after_implicit = collectgarbage("count") + +local time_implicit = time_after_implicit - time_before_implicit +local mem_implicit = mem_after_implicit - mem_before_implicit + +print(string.format(" Time: %.4f seconds", time_implicit)) +print(string.format(" Memory: %.2f KB", mem_implicit)) +print(string.format(" Avg time per menu: %.4f ms", (time_implicit / ITERATIONS_PER_TEST) * 1000)) +print() + +-- Test 2: With explicit mode="retained" flags +print("Running Test 2: With explicit mode='retained' flags...") +FlexLove.init({ immediateMode = false, theme = "space" }) +collectgarbage("collect") +local mem_before_explicit = collectgarbage("count") +local time_before_explicit = os.clock() + +for i = 1, ITERATIONS_PER_TEST do + local menu = create_settings_menu_with_mode_flag(true) +end + +local time_after_explicit = os.clock() +collectgarbage("collect") +local mem_after_explicit = collectgarbage("count") + +local time_explicit = time_after_explicit - time_before_explicit +local mem_explicit = mem_after_explicit - mem_before_explicit + +print(string.format(" Time: %.4f seconds", time_explicit)) +print(string.format(" Memory: %.2f KB", mem_explicit)) +print(string.format(" Avg time per menu: %.4f ms", (time_explicit / ITERATIONS_PER_TEST) * 1000)) +print() + +-- Calculate differences +print("=" .. string.rep("=", 78)) +print("RESULTS COMPARISON") +print("=" .. string.rep("=", 78)) +print() + +local time_diff = time_explicit - time_implicit +local time_percent = (time_diff / time_implicit) * 100 +local mem_diff = mem_explicit - mem_implicit + +print(string.format("Time Difference:")) +print(string.format(" Without mode flag: %.4f seconds", time_implicit)) +print(string.format(" With mode flag: %.4f seconds", time_explicit)) +print(string.format(" Difference: %.4f seconds (%+.2f%%)", time_diff, time_percent)) +print() + +print(string.format("Memory Difference:")) +print(string.format(" Without mode flag: %.2f KB", mem_implicit)) +print(string.format(" With mode flag: %.2f KB", mem_explicit)) +print(string.format(" Difference: %+.2f KB", mem_diff)) +print() + +-- Interpretation +print("INTERPRETATION:") +print() +if math.abs(time_percent) < 5 then + print(" ✓ Performance is essentially identical (< 5% difference)") + print(" The explicit mode flag has negligible impact on performance.") +elseif time_percent > 0 then + print(string.format(" ⚠ Explicit mode flag is %.2f%% SLOWER", time_percent)) + print(" This indicates overhead from mode checking/resolution.") +else + print(string.format(" ✓ Explicit mode flag is %.2f%% FASTER", -time_percent)) + print(" This indicates potential optimization benefits.") +end +print() + +if math.abs(mem_diff) < 50 then + print(" ✓ Memory usage is essentially identical (< 50 KB difference)") +elseif mem_diff > 0 then + print(string.format(" ⚠ Explicit mode flag uses %.2f KB MORE memory", mem_diff)) +else + print(string.format(" ✓ Explicit mode flag uses %.2f KB LESS memory", -mem_diff)) +end +print() + +print("RECOMMENDATION:") +print() +if math.abs(time_percent) < 5 and math.abs(mem_diff) < 50 then + print(" The explicit mode='retained' flag provides clarity and explicitness") + print(" without any meaningful performance cost. It's recommended for:") + print(" - Code readability (makes intent explicit)") + print(" - Future-proofing (if global mode changes)") + print(" - Mixed-mode UIs (where some elements are immediate)") +else + print(" Consider the trade-offs based on your specific use case.") +end +print() + +print("=" .. string.rep("=", 78)) +print("Profile complete!") +print("=" .. string.rep("=", 78)) diff --git a/testing/__tests__/element_mode_override_test.lua b/testing/__tests__/element_mode_override_test.lua new file mode 100644 index 0000000..e909264 --- /dev/null +++ b/testing/__tests__/element_mode_override_test.lua @@ -0,0 +1,343 @@ +-- Test suite for Element mode override functionality + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local ErrorHandler = require("modules.ErrorHandler") + +-- Initialize ErrorHandler +ErrorHandler.init({}) + +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + +-- Load FlexLove which properly initializes all dependencies +local FlexLove = require("FlexLove") +local StateManager = require("modules.StateManager") + +TestElementModeOverride = {} + +function TestElementModeOverride:setUp() + -- Initialize FlexLove in immediate mode by default + FlexLove.init({ immediateMode = true }) + FlexLove.beginFrame() +end + +function TestElementModeOverride:tearDown() + if FlexLove.getMode() == "immediate" then + FlexLove.endFrame() + end + -- Reset to default state + FlexLove.init({ immediateMode = false }) +end + +-- Test 01: Mode resolution - explicit immediate +function TestElementModeOverride:test_modeResolution_explicitImmediate() + local element = FlexLove.new({ + mode = "immediate", + text = "Test", + }) + + luaunit.assertEquals(element._elementMode, "immediate") +end + +-- Test 02: Mode resolution - explicit retained +function TestElementModeOverride:test_modeResolution_explicitRetained() + local element = FlexLove.new({ + mode = "retained", + text = "Test", + }) + + luaunit.assertEquals(element._elementMode, "retained") +end + +-- Test 03: Mode resolution - nil uses global (immediate) +function TestElementModeOverride:test_modeResolution_nilUsesGlobalImmediate() + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + local element = FlexLove.new({ + text = "Test", + }) + + luaunit.assertEquals(element._elementMode, "immediate") +end + +-- Test 04: Mode resolution - nil uses global (retained) +function TestElementModeOverride:test_modeResolution_nilUsesGlobalRetained() + FlexLove.setMode("retained") + + local element = FlexLove.new({ + text = "Test", + }) + + luaunit.assertEquals(element._elementMode, "retained") +end + +-- Test 05: ID auto-generation only for immediate mode +function TestElementModeOverride:test_idGeneration_onlyForImmediate() + -- Immediate element without ID should get auto-generated ID + local immediateEl = FlexLove.new({ + mode = "immediate", + text = "Immediate", + }) + luaunit.assertNotNil(immediateEl.id) + luaunit.assertNotEquals(immediateEl.id, "") + + -- Retained element without ID should have empty ID + local retainedEl = FlexLove.new({ + mode = "retained", + text = "Retained", + }) + luaunit.assertEquals(retainedEl.id, "") +end + +-- Test 06: Immediate override in retained context +function TestElementModeOverride:test_immediateOverrideInRetainedContext() + FlexLove.setMode("retained") + + local element = FlexLove.new({ + mode = "immediate", + id = "test-immediate", + text = "Immediate in retained context", + }) + + luaunit.assertEquals(element._elementMode, "immediate") + luaunit.assertEquals(element.id, "test-immediate") +end + +-- Test 07: Retained override in immediate context +function TestElementModeOverride:test_retainedOverrideInImmediateContext() + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + local element = FlexLove.new({ + mode = "retained", + text = "Retained in immediate context", + }) + + luaunit.assertEquals(element._elementMode, "retained") + luaunit.assertEquals(element.id, "") -- Should not auto-generate ID +end + +-- Test 08: Mixed-mode parent-child (immediate parent, retained child) +function TestElementModeOverride:test_mixedMode_immediateParent_retainedChild() + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + local parent = FlexLove.new({ + mode = "immediate", + id = "parent", + text = "Parent", + }) + + local child = FlexLove.new({ + mode = "retained", + parent = parent, + text = "Child", + }) + + luaunit.assertEquals(parent._elementMode, "immediate") + luaunit.assertEquals(child._elementMode, "retained") + -- Child should not inherit parent mode + luaunit.assertNotEquals(child._elementMode, parent._elementMode) +end + +-- Test 09: Mixed-mode parent-child (retained parent, immediate child) +function TestElementModeOverride:test_mixedMode_retainedParent_immediateChild() + FlexLove.setMode("retained") + + local parent = FlexLove.new({ + mode = "retained", + text = "Parent", + }) + + local child = FlexLove.new({ + mode = "immediate", + id = "child", + parent = parent, + text = "Child", + }) + + luaunit.assertEquals(parent._elementMode, "retained") + luaunit.assertEquals(child._elementMode, "immediate") + luaunit.assertEquals(child.id, "child") +end + +-- Test 10: Frame registration only for immediate elements +function TestElementModeOverride:test_frameRegistration_onlyImmediate() + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + local immediate1 = FlexLove.new({ + mode = "immediate", + id = "imm1", + text = "Immediate 1", + }) + + local retained1 = FlexLove.new({ + mode = "retained", + text = "Retained 1", + }) + + local immediate2 = FlexLove.new({ + mode = "immediate", + id = "imm2", + text = "Immediate 2", + }) + + -- Count immediate elements in _currentFrameElements + local immediateCount = 0 + for _, element in ipairs(FlexLove._currentFrameElements) do + if element._elementMode == "immediate" then + immediateCount = immediateCount + 1 + end + end + + luaunit.assertEquals(immediateCount, 2) +end + +-- Test 11: Layout calculation for retained parent with immediate children +function TestElementModeOverride:test_layoutRetainedParentWithImmediateChildren() + FlexLove.setMode("retained") + + -- Create retained parent with flex layout + local parent = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + positioning = "flex", + flexDirection = "horizontal", + gap = 10, + }) + + -- Switch to immediate mode and add children + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + local child1 = FlexLove.new({ + mode = "immediate", + id = "child1", + parent = parent, + width = 100, + height = 50, + }) + + local child2 = FlexLove.new({ + mode = "immediate", + id = "child2", + parent = parent, + width = 100, + height = 50, + }) + + FlexLove.endFrame() + + -- Verify children are positioned correctly by flex layout + luaunit.assertEquals(child1.x, 0) + luaunit.assertEquals(child1.y, 0) + luaunit.assertEquals(child2.x, 110) -- 100 + 10 gap + luaunit.assertEquals(child2.y, 0) +end + +-- Test 12: Deeply nested mixed modes (retained -> immediate -> retained) +function TestElementModeOverride:test_deeplyNestedMixedModes() + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + -- Level 1: Retained root + local root = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + positioning = "flex", + flexDirection = "vertical", + gap = 5, + }) + + -- Level 2: Immediate child of retained parent + local middle = FlexLove.new({ + mode = "immediate", + id = "middle", + parent = root, + width = 400, + height = 300, + positioning = "flex", + flexDirection = "horizontal", + gap = 10, + }) + + -- Level 3: Retained grandchildren + local leaf1 = FlexLove.new({ + mode = "retained", + parent = middle, + width = 100, + height = 50, + }) + + local leaf2 = FlexLove.new({ + mode = "retained", + parent = middle, + width = 100, + height = 50, + }) + + FlexLove.endFrame() + + -- Verify all levels are positioned correctly + luaunit.assertEquals(root.x, 0) + luaunit.assertEquals(root.y, 0) + luaunit.assertEquals(middle.x, 0) + luaunit.assertEquals(middle.y, 0) + luaunit.assertEquals(leaf1.x, 0) + luaunit.assertEquals(leaf1.y, 0) + luaunit.assertEquals(leaf2.x, 110) -- 100 + 10 gap + luaunit.assertEquals(leaf2.y, 0) +end + +-- Test 13: Immediate children of retained parents receive updates +function TestElementModeOverride:test_immediateChildrenOfRetainedParentsGetUpdated() + FlexLove.setMode("retained") + + local updateCount = 0 + + -- Create retained parent + local parent = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + }) + + -- Switch to immediate mode for child + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + -- Create immediate child that tracks updates + local child = FlexLove.new({ + mode = "immediate", + id = "updateTest", + parent = parent, + width = 100, + height = 50, + }) + + -- Manually call update on the child to simulate what endFrame should do + -- In the real implementation, endFrame calls update on retained parents, + -- which cascades to immediate children + FlexLove.endFrame() + + -- The child should be in the state manager + local state = StateManager.getState("updateTest") + luaunit.assertNotNil(state) +end + +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/__tests__/mixed_mode_events_test.lua b/testing/__tests__/mixed_mode_events_test.lua new file mode 100644 index 0000000..4488c3b --- /dev/null +++ b/testing/__tests__/mixed_mode_events_test.lua @@ -0,0 +1,192 @@ +-- Test event handling for immediate children of retained parents + +package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Load love stub before anything else +require("testing.loveStub") + +local luaunit = require("testing.luaunit") +local ErrorHandler = require("modules.ErrorHandler") + +-- Initialize ErrorHandler +ErrorHandler.init({}) + +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + +local FlexLove = require("FlexLove") +local Color = require("modules.Color") +local Element = require("modules.Element") + +TestMixedModeEvents = {} + +function TestMixedModeEvents:setUp() + FlexLove.init({ immediateMode = false }) +end + +function TestMixedModeEvents:tearDown() + if FlexLove.getMode() == "immediate" then + FlexLove.endFrame() + end + FlexLove.init({ immediateMode = false }) +end + +-- Test that immediate children of retained parents can handle events +function TestMixedModeEvents:test_immediateChildOfRetainedParentHandlesEvents() + local eventFired = false + local eventType = nil + + -- Create retained parent + local parent = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + }) + + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + -- Create immediate child with event handler + local child = FlexLove.new({ + mode = "immediate", + id = "eventTestChild", + parent = parent, + x = 0, + y = 0, + width = 100, + height = 50, + onEvent = function(element, event) + eventFired = true + eventType = event.type + end, + }) + + FlexLove.endFrame() + + -- Verify child is positioned correctly + luaunit.assertEquals(child.x, 0) + luaunit.assertEquals(child.y, 0) + + -- Manually call the event handler (simulating event processing) + -- In the real app, this would be triggered by mousepressed/released + if child.onEvent then + child.onEvent(child, { type = "release", x = 50, y = 25, button = 1 }) + end + + -- Verify event was handled + luaunit.assertTrue(eventFired) + luaunit.assertEquals(eventType, "release") +end + +-- Test that hover state is tracked for immediate children +function TestMixedModeEvents:test_immediateChildOfRetainedParentTracksHover() + FlexLove.setMode("retained") + + -- Create retained parent + local parent = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + }) + + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + -- Create immediate child + local child = FlexLove.new({ + mode = "immediate", + id = "hoverTestChild", + parent = parent, + x = 100, + y = 100, + width = 100, + height = 50, + }) + + FlexLove.endFrame() + + -- Child should have event handler module + luaunit.assertNotNil(child._eventHandler) + + -- Verify child can track hover state (stored in StateManager for immediate mode) + -- The actual hover detection happens in Element's event processing + luaunit.assertEquals(child._elementMode, "immediate") +end + +-- Test multiple immediate children with different event handlers +function TestMixedModeEvents:test_multipleImmediateChildrenHandleEventsIndependently() + local button1Clicked = false + local button2Clicked = false + + FlexLove.setMode("retained") + + -- Create retained parent + local parent = FlexLove.new({ + mode = "retained", + width = 800, + height = 600, + positioning = "flex", + flexDirection = "horizontal", + gap = 10, + }) + + FlexLove.setMode("immediate") + FlexLove.beginFrame() + + -- Create two immediate button children + local button1 = FlexLove.new({ + mode = "immediate", + id = "button1", + parent = parent, + width = 100, + height = 50, + onEvent = function(element, event) + if event.type == "release" then + button1Clicked = true + end + end, + }) + + local button2 = FlexLove.new({ + mode = "immediate", + id = "button2", + parent = parent, + width = 100, + height = 50, + onEvent = function(element, event) + if event.type == "release" then + button2Clicked = true + end + end, + }) + + FlexLove.endFrame() + + -- Verify buttons are positioned correctly + luaunit.assertEquals(button1.x, 0) + luaunit.assertEquals(button2.x, 110) -- 100 + 10 gap + + -- Simulate clicking button1 + if button1.onEvent then + button1.onEvent(button1, { type = "release", x = 50, y = 25, button = 1 }) + end + + luaunit.assertTrue(button1Clicked) + luaunit.assertFalse(button2Clicked) + + -- Simulate clicking button2 + if button2.onEvent then + button2.onEvent(button2, { type = "release", x = 150, y = 25, button = 1 }) + end + + luaunit.assertTrue(button1Clicked) + luaunit.assertTrue(button2Clicked) +end + +os.exit(luaunit.LuaUnit.run())