diff --git a/docs/new-features.md b/docs/new-features.md index 725fbc09..cea7bdb3 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -551,3 +551,59 @@ file pickers are ordinary desktop dialogs and still appear normally, and a run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt out. + +## A faster, higher-contrast launcher + +The launcher and the save editor were rebuilt on one small immediate-mode UI +kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The +visible result is that the launcher is quick: building and drawing a frame +went from about 9 ms to under 1 ms on the same machine and the same data, at +every window size, so the window keeps up with the pointer instead of +trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`. + +**Nothing blocks the window any more.** Fetching a mod index, checking a mod +for updates, listing versions, downloading an install and pulling thumbnails +all run on background threads. Opening FIND MODS on a cold cache used to +freeze the launcher for as long as the server took -- often minutes, with no +indication anything was happening. Mod indexes are now also fetched at boot, +so the tab is usually already populated by the time you reach it. + +**Anything you wait on says so.** Every operation that takes time raises a +loading panel with a spinner or a progress bar that cannot be clicked around +or dismissed, so a half-finished install can never be interrupted by a stray +click. Work that only affects one row (a mod's update check) shows a small +spinner on that row instead and leaves the rest of the list usable. + +**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings, +release notes and the version list all show a fixed number of rows with a +pager underneath, and the number of rows comes from the window height -- a +tall window shows more, a phone shows fewer. A long list costs exactly what a +short one does. The mouse wheel turns pages. + +**Updates live in the top right.** The in-app updater moved next to the +settings gear and pulses when an update is waiting, instead of sitting in a +banner at the bottom of the page that you had to scroll to notice. Checking +for updates from there shows a loader like everything else. + +**The look.** Black background, white outlines, no gradients or glows, and +buttons that are solid colour-coded keys: green commits, blue navigates, red +destroys, yellow wants attention. The three game tabs keep their red, blue +and gold cartridge colours. Everything is about a third larger than before. +The save editor follows the same theme, and adding an item there is now a +searchable pop-up like adding a Pokemon, rather than a cramped list wedged +into the tab. + +**Reset rebinds.** Input rebinds are additive, so there was no in-game way to +undo one. A RESET REBINDS row in Settings, and a matching button under Touch +Controls on each game tab, restore the stock keyboard, gamepad and touch +layout. Both ask twice. + +## Launch options: boot straight into a game + +`love . --game red` skips the launcher and starts that game; `--slot ` picks the save slot to load, and `--launcher` forces the launcher +anyway. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that can +only pass environment variables. This is for one-click entries: a desktop +shortcut per game, a Steam entry, or a handheld frontend. Asking for a game +whose ROM has not been imported opens the launcher on that game's tab rather +than failing. diff --git a/libs/flexlove/FlexLove.lua b/libs/flexlove/FlexLove.lua deleted file mode 100644 index eaf749af..00000000 --- a/libs/flexlove/FlexLove.lua +++ /dev/null @@ -1,1955 +0,0 @@ -local packageName = ... or "FlexLove" -local modulePath = packageName:match("(.-)[^%.]+$") -- Get the module path prefix (e.g., "libs." or "") --- If modulePath is empty (e.g., require("FlexLove")), use the package name -if modulePath == "" then - modulePath = packageName .. "." -end - -local function req(name) - return require(modulePath .. "modules." .. name) -end - ----@type ErrorHandler -local ErrorHandler = req("ErrorHandler") -local ModuleLoader = req("ModuleLoader") -ModuleLoader.init({ ErrorHandler = ErrorHandler }) - -local function safeReq(name, isOptional) - local module = ModuleLoader.safeRequire(modulePath .. "modules." .. name, isOptional) - if isOptional and module and module._isStub then - return nil - end - return module -end - --- Required core modules -local utils = req("utils") -local Calc = req("Calc") -local Units = req("Units") -local Context = req("Context") ----@type StateManager -local StateManager = req("StateManager") -local RoundedRect = req("RoundedRect") -local Grid = req("Grid") -local InputEvent = req("InputEvent") -local TextEditor = req("TextEditor") ----@type LayoutEngine -local LayoutEngine = req("LayoutEngine") -local Renderer = req("Renderer") ----@type EventHandler -local EventHandler = req("EventHandler") -local ScrollManager = req("ScrollManager") ----@type ZIndex -local ZIndex = req("ZIndex") ----@type Element -local Element = req("Element") ----@type Color -local Color = req("Color") - --- Lua 5.2+ compatibility for unpack (bare global `unpack` is nil under --- Lua 5.4, which the stock test runner uses). mirrors the shim in --- modules/Blur.lua:2 and modules/Element.lua:162. -local unpack = table.unpack or unpack - ----@type Select -local Select = req("Select") - --- Behavior: mouse/touch event handling, pressed-state, hit-testing (task 02). --- Auto-attaches to interactive elements via shouldAttach(props). -local Clickable = req("behaviors.Clickable") - --- Behavior: Renderer ownership + theme-state rendering (task 07). Owns the --- single Renderer:draw call and creates the per-element Renderer. Attaches to --- every renderable element (see behaviors/Themed.lua for the always-attach --- rationale). Must precede Clickable in the registry so its core Renderer:draw --- runs before Clickable's pressed-state overlay (onDraw layering). -local Themed = req("behaviors.Themed") - --- Behavior: image loading + image rendering config (task 07). Enriches the --- shared element._renderer with image config, runs the deferred image-load --- pipeline, and persists _loadedImage across immediate-mode frames. Attaches to --- elements with imagePath/image. -local Imageable = req("behaviors.Imageable") - --- Behavior: animation update, interpolation, chaining, transition wiring --- (task 06). Auto-attaches to elements that pre-declare `transitions`, and --- late-attaches on demand via Animated.ensureAttached when an animation is --- created post-construction (animateTo/fadeIn/direct assignment/transition fire). -local Animated = req("behaviors.Animated") - --- Behavior: Select state-machine lifecycle (task 05). Owns select subsystem --- init, managed-frame layout sync each frame, and select save/restore. Auto- --- attaches to elements with selectParent or selectOption props. -local Selectable = req("behaviors.Selectable") - --- Behavior: TextEditor subsystem ownership — text editing, cursor management, --- text selection, text-related input handling, and text-editor save/restore --- (task 04). Auto-attaches to editable elements and text-bearing elements via --- shouldAttach(props); onAttach allocates the TextEditor (editable only). --- Element retains 1-line forwarders routed through this module for the 27 --- text-editor delegate methods, eliminating the `if self._textEditor` guards. -local TextEditable = req("behaviors.TextEditable") - --- Behavior: ScrollManager lifecycle (task 03, landed via the task 08 capstone). --- Owns ScrollManager creation + immediate-mode scrollbar interaction-state --- restore (formerly Element:_initScrollManager). Auto-attaches to elements that --- declare overflow / overflowX / overflowY. Placed late in the registry: its --- onAttach creates the ScrollManager, which no other behavior's onAttach --- depends on. The ScrollManager update / scrollbar draw / state save-restore --- stay inline in Element:update / Element:draw / Element:saveState as --- unconditional 1-line delegates (task 09 folds them into hooks). -local Scrollable = req("behaviors.Scrollable") - --- Behavior: generic public-property persistence across the immediate-mode --- recreation cycle (task 12). Owns the `_props` snapshot (event-driven mutations --- to `text` / `display` / `opacity` / ... that must survive per-frame Element --- recreation). Auto-attaches to every element; placed LAST in the registry so --- its restoreState overrides subsystem-hydrated state, preserving the legacy --- restore ordering (behaviors first, `_props` tail). With this behavior in --- place, Element:saveState / Element:restoreState collapse to a pure --- behavior-dispatch loop and Element owns zero property-extraction logic. -local Persistable = req("behaviors.Persistable") - --- Optional modules (can be excluded in minimal builds) -local Blur = safeReq("Blur", true) ----@type Performance -local Performance = safeReq("Performance", true) ----@type KeyboardNavigation -local KeyboardNavigation = safeReq("KeyboardNavigation", true) ----@type FocusIndicator -local FocusIndicator = safeReq("FocusIndicator", true) -local ImageRenderer = safeReq("ImageRenderer", true) -local ImageScaler = safeReq("ImageScaler", true) -local NinePatch = safeReq("NinePatch", true) -local ImageCache = safeReq("ImageCache", true) -local GestureRecognizer = safeReq("GestureRecognizer", true) ----@type PropertySchema -local PropertySchema = req("PropertySchema") ----@type Animation -local Animation = safeReq("Animation", true) ----@type Theme -local Theme = safeReq("Theme", true) - --- Handle Animation.Transform safely -local Transform = Animation and Animation.Transform or nil - -local enums = utils.enums - -local flexlove = Context -flexlove._VERSION = "0.15.0" -flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox" -flexlove._URL = "https://github.com/mikefreno/FlexLove" -flexlove._LICENSE = [[ - MIT License - - Copyright (c) 2025 Mike Freno - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. -]] - --- GC (Garbage Collection) configuration ----@type GCConfig -flexlove._gcConfig = { - strategy = "auto", -- "auto", "periodic", "manual", "disabled" - memoryThreshold = 100, -- MB before forcing GC - interval = 60, -- Frames between GC steps (for periodic mode) - stepSize = 200, -- Work units per GC step (higher = more aggressive) -} ----@type GCState -flexlove._gcState = { - framesSinceLastGC = 0, - lastMemory = 0, - gcCount = 0, -} - --- Deferred callback queue for operations that cannot run while Canvas is active ----@type function[] -flexlove._deferredCallbacks = {} - --- Track accumulated delta time for immediate mode updates -flexlove._accumulatedDt = 0 - --- Touch ownership tracking: maps touch ID (string) to the element that owns it ----@type table -flexlove._touchOwners = {} - --- Touch-drag scroll tracking: survives immediate-mode element recreation. --- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into --- StateManager on every move (same contract as wheelmoved). ----@type table -flexlove._touchScroll = {} - ----@type table -flexlove._mouseButtonStates = {} - --- Shared GestureRecognizer instance for touch routing (initialized in init()) ----@type GestureRecognizer|nil -flexlove._gestureRecognizer = nil - ---- Check if FlexLove initialization is complete and ready to create elements ---- Use this before creating elements to avoid automatic queueing ----@return boolean ready True if FlexLove is initialized and ready to use -function flexlove.isReady() - return flexlove._initState == "ready" -end - ---- Set up FlexLove for your application's specific needs - configure responsive scaling, theming, rendering mode, and debugging tools ---- Use this to establish a consistent UI foundation that adapts to different screen sizes and provides performance insights ---- After initialization, any queued element creation calls will be automatically processed ----@param config FlexLoveConfig? -function flexlove.init(config) - flexlove._initState = "initializing" - config = config or {} - - flexlove._ErrorHandler = ErrorHandler.init({ - includeStackTrace = config.includeStackTrace, - logLevel = config.reportingLogLevel, - logTarget = config.errorLogTarget, - logFile = config.errorLogFile, - maxLogSize = config.errorLogMaxSize, - maxLogFiles = config.maxErrorLogFiles, - enableRotation = config.errorLogRotateEnabled, - }) - - -- Initialize Performance if available - if Performance then - flexlove._Performance = Performance.init({ - -- ~= false (not `or true`): `performanceMonitoring = false` must - -- actually disable the per-frame timers + memory sampling. - enabled = config.performanceMonitoring ~= false, - hudEnabled = false, -- Start with HUD disabled - hudToggleKey = config.performanceHudKey or "f3", - hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, - warningThresholdMs = config.performanceWarningThreshold or 13.0, - criticalThresholdMs = config.performanceCriticalThreshold or 16.67, - logToConsole = config.performanceLogToConsole or false, - logWarnings = config.performanceWarnings or false, - warningsEnabled = config.performanceWarnings or false, - memoryProfiling = config.memoryProfiling or config.immediateMode and true or false, - }, { ErrorHandler = flexlove._ErrorHandler }) - - if config.immediateMode then - flexlove._Performance:registerTableForMonitoring( - "StateManager.stateStore", - StateManager._getInternalState().stateStore - ) - flexlove._Performance:registerTableForMonitoring( - "StateManager.stateMetadata", - StateManager._getInternalState().stateMetadata - ) - end - else - flexlove._Performance = Performance - end - - -- Initialize optional modules if available - if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageRenderer") then - ImageRenderer.init({ ErrorHandler = flexlove._ErrorHandler, utils = utils }) - end - - if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageScaler") then - ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) - end - - if ModuleLoader.isModuleLoaded(modulePath .. "modules.NinePatch") then - NinePatch.init({ ErrorHandler = flexlove._ErrorHandler }) - end - - -- Initialize Blur module with immediate mode optimization config - if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then - local blurOptimizations = config.immediateModeBlurOptimizations - if blurOptimizations == nil then - blurOptimizations = true -- Default to enabled - end - Blur.init({ - ErrorHandler = flexlove._ErrorHandler, - immediateModeOptimizations = blurOptimizations and config.immediateMode or false, - }) - end - - -- Initialize required modules - StateManager.init({ ErrorHandler = flexlove._ErrorHandler }) - Calc.init({ ErrorHandler = flexlove._ErrorHandler }) - Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler, Calc = Calc }) - Color.init({ ErrorHandler = flexlove._ErrorHandler }) - utils.init({ ErrorHandler = flexlove._ErrorHandler }) - - -- Initialize optional ImageCache module - if ModuleLoader.isModuleLoaded(modulePath .. "modules.ImageCache") then - ImageCache.init({ ErrorHandler = flexlove._ErrorHandler }) - end - - -- Initialize optional Animation module - if ModuleLoader.isModuleLoaded(modulePath .. "modules.Animation") then - Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color }) - end - - -- Initialize optional Theme module - if ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then - Theme.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color, utils = utils }) - end - - LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, utils = utils }) - EventHandler.init({ - ErrorHandler = flexlove._ErrorHandler, - Performance = flexlove._Performance, - InputEvent = InputEvent, - utils = utils, - Context = Context, - }) - - -- Initialize shared GestureRecognizer for touch routing - if GestureRecognizer then - flexlove._gestureRecognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils }) - end - - -- Initialize KeyboardNavigation and FocusIndicator if enabled - local keyboardConfig = config.keyboardNavigation - if - KeyboardNavigation - and (keyboardConfig == true or (type(keyboardConfig) == "table" and keyboardConfig.enabled ~= false)) - then - KeyboardNavigation.init({ - Context = Context, - Element = Element, - ErrorHandler = flexlove._ErrorHandler, - utils = utils, - InputEvent = InputEvent, - }) - - if FocusIndicator then - FocusIndicator.init({ Context = Context, Color = Color }) - KeyboardNavigation.FocusIndicator = FocusIndicator - -- Also set FocusIndicator reference in EventHandler for clearing on mouse click - EventHandler._FocusIndicator = FocusIndicator - -- Note: FocusIndicator is only updated from keyboard navigation (_focusElement) - -- Mouse clicks and activation clear the indicator - end - - -- Apply configuration if provided - flexlove._applyKeyboardNavConfig(keyboardConfig) - end - - flexlove._defaultDependencies = { - Context = Context, - Theme = Theme, - Color = Color, - Calc = Calc, - Units = Units, - Blur = Blur, - ImageRenderer = ImageRenderer, - ImageScaler = ImageScaler, - NinePatch = NinePatch, - RoundedRect = RoundedRect, - ImageCache = ImageCache, - utils = utils, - Grid = Grid, - InputEvent = InputEvent, - GestureRecognizer = GestureRecognizer, - StateManager = StateManager, - TextEditor = TextEditor, - LayoutEngine = LayoutEngine, - Renderer = Renderer, - EventHandler = EventHandler, - ScrollManager = ScrollManager, - ErrorHandler = flexlove._ErrorHandler, - Performance = flexlove._Performance, - Transform = Transform, - Animation = Animation, - ZIndex = ZIndex, - Select = Select, - PropertySchema = PropertySchema, - -- Behavior registry (behavior-mode-unification task 09). Two ordering - -- invariants: - -- * Update: Animated (geometry) → Scrollable (scroll interaction) → - -- Clickable (hit-testing) — animated geometry must be current for - -- hit-testing, and scrollbar press state must be set before Clickable's - -- EventHandler processes mouse events. - -- * Draw: Themed (core Renderer:draw) runs before Clickable (pressed-state - -- overlay); Scrollable (drawLayer="overlay") is dispatched AFTER children - -- for scrollbar-on-top. Imageable/Animated/Selectable/TextEditable onDraw - -- are no-ops, so their position is unconstrained for layering. - -- 7 entries. (task 09 reordered Animated+Scrollable ahead of Clickable.) - clickableBehaviors = { Themed, Clickable, Imageable }, - -- Persistable is the registry tail (task 12): its restoreState applies the - -- `_props` override AFTER every subsystem behavior has hydrated, preserving - -- the legacy restore ordering (behaviors first, `_props` last). - behaviors = { Themed, Animated, Scrollable, Clickable, Imageable, Selectable, TextEditable, Persistable }, - TextEditable = TextEditable, - } - - -- Initialize Element module with dependencies - Element.init(flexlove._defaultDependencies) - - if config.baseScale then - flexlove.baseScale = { - width = config.baseScale.width or 1920, - height = config.baseScale.height or 1080, - } - - local currentWidth, currentHeight = Units.getViewport() - flexlove.scaleFactors.x = currentWidth / flexlove.baseScale.width - flexlove.scaleFactors.y = currentHeight / flexlove.baseScale.height - end - - if config.theme and ModuleLoader.isModuleLoaded(modulePath .. "modules.Theme") then - local success, err = pcall(function() - if type(config.theme) == "string" then - Theme.load(config.theme) - Theme.setActive(config.theme) - flexlove.defaultTheme = config.theme - elseif type(config.theme) == "table" then - local theme = Theme.new(config.theme) - Theme.setActive(theme) - flexlove.defaultTheme = theme.name - end - end) - - if not success then - flexlove._ErrorHandler:warn("FlexLove", "THM_005", { - error = tostring(err), - }) - end - end - - local immediateMode = config.immediateMode or false - flexlove.setMode(immediateMode and "immediate" or "retained") - - flexlove._autoFrameManagement = config.autoFrameManagement or false - - -- Configure GC strategy - if config.gcStrategy then - flexlove._gcConfig.strategy = config.gcStrategy - end - if config.gcMemoryThreshold then - flexlove._gcConfig.memoryThreshold = config.gcMemoryThreshold - end - if config.gcInterval then - flexlove._gcConfig.interval = config.gcInterval - end - if config.gcStepSize then - flexlove._gcConfig.stepSize = config.gcStepSize - end - - if config.stateRetentionFrames or config.maxStateEntries then - StateManager.configure({ - stateRetentionFrames = config.stateRetentionFrames, - maxStateEntries = config.maxStateEntries, - }) - end - flexlove.initialized = true - flexlove._initState = "ready" - - -- Configure debug draw overlay - flexlove._debugDraw = config.debugDraw or false - flexlove._debugDrawKey = config.debugDrawKey or nil - - -- Process all queued element creations - local queue = flexlove._initQueue - flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues - - for _, item in ipairs(queue) do - local element = Element.new(item.props) - if item.callback and type(item.callback) == "function" then - local success, err = pcall(item.callback, element) - if not success then - flexlove._ErrorHandler:warn( - "FlexLove", - string.format("Failed to execute queued element callback: %s", tostring(err)) - ) - end - end - end -end - ---- Enable keyboard navigation after initialization (for deferred or conditional setup) ---- Useful when you need to conditionally enable keyboard navigation based on runtime conditions ---- Automatically initializes KeyboardNavigation and FocusIndicator modules if not already initialized ----@param config KeyboardNavigationConfig? Optional configuration table ---- @usage ---- -- Enable with defaults ---- FlexLove.enableKeyboardNavigation() ---- ---- -- Enable with custom configuration ---- FlexLove.enableKeyboardNavigation({ ---- directionalNavigation = true, ---- wrapAround = false, ---- focusIndicator = { ---- enabled = true, ---- color = {1, 0.8, 0, 0.8}, ---- lineWidth = 3, ---- }, ---- }) ---- Enable debug mode for keyboard navigation ---- Use this to troubleshoot keyboard navigation issues ----@param enabled boolean -function flexlove.setKeyboardNavigationDebug(enabled) - if KeyboardNavigation and KeyboardNavigation.config then - KeyboardNavigation.config.debugMode = enabled - print(string.format("[FlexLove] Keyboard navigation debug mode: %s", tostring(enabled))) - end -end - ---- Apply keyboard navigation configuration (internal helper) ----@param config table -function flexlove._applyKeyboardNavConfig(config) - if type(config) ~= "table" then - return - end - - if config.enabled ~= nil then - KeyboardNavigation.config.enabled = config.enabled - end - if config.directionalNavigation ~= nil then - KeyboardNavigation.config.directionalNavigation = config.directionalNavigation - end - if config.wrapAround ~= nil then - KeyboardNavigation.config.wrapAround = config.wrapAround - end - if config.dropFocusOnSelection ~= nil then - KeyboardNavigation.config.dropFocusOnSelection = config.dropFocusOnSelection - end - - if config.focusIndicator and FocusIndicator then - local fiConfig = config.focusIndicator - if fiConfig.enabled ~= nil then - FocusIndicator.config.enabled = fiConfig.enabled - end - if fiConfig.draw ~= nil then - FocusIndicator.config.draw = fiConfig.draw - end - if fiConfig.color then - FocusIndicator.setColor( - fiConfig.color[1] or 0.2, - fiConfig.color[2] or 0.6, - fiConfig.color[3] or 1.0, - fiConfig.color[4] or 0.8 - ) - end - if fiConfig.lineWidth ~= nil then - FocusIndicator.config.lineWidth = fiConfig.lineWidth - end - if fiConfig.pulseEnabled ~= nil then - FocusIndicator.config.pulseEnabled = fiConfig.pulseEnabled - end - end -end - ---- Enable keyboard navigation after initialization (for deferred or conditional setup) ---- Useful when you need to conditionally enable keyboard navigation based on runtime conditions ---- Automatically initializes KeyboardNavigation and FocusIndicator modules if not already initialized ----@usage ---- -- Enable with defaults ---- FlexLove.enableKeyboardNavigation() ---- ---- -- Enable with custom configuration ---- FlexLove.enableKeyboardNavigation({ ---- directionalNavigation = true, ---- wrapAround = false, ---- dropFocusOnSelection = false, ---- focusIndicator = { ---- enabled = true, ---- color = {1, 0.8, 0, 0.8}, ---- lineWidth = 3, ---- draw = function(element, bounds, style) end, ---- }, ---- }) ----@param config KeyboardNavigationConfig -function flexlove.enableKeyboardNavigation(config) - if not KeyboardNavigation then - return - end - config = config or {} - - -- Check if already initialized - if KeyboardNavigation.config and KeyboardNavigation._deps then - -- Already initialized, just apply config if provided - flexlove._applyKeyboardNavConfig(config) - return - end - - -- Initialize KeyboardNavigation - KeyboardNavigation.init({ - Context = Context, - Element = Element, - ErrorHandler = flexlove._ErrorHandler, - utils = utils, - InputEvent = InputEvent, - }) - - -- Initialize FocusIndicator if available - if FocusIndicator then - FocusIndicator.init({ Context = Context, Color = Color }) - KeyboardNavigation.FocusIndicator = FocusIndicator - -- Also set FocusIndicator reference in EventHandler for clearing on mouse click - EventHandler._FocusIndicator = FocusIndicator - -- Note: FocusIndicator is only updated from keyboard navigation (_focusElement) - -- Mouse clicks and activation clear the indicator - end - - flexlove._applyKeyboardNavConfig(config) -end - ---- Safely schedule operations that modify LÖVE's rendering state (like window mode changes) to execute after all canvas operations complete ---- Prevents crashes from attempting canvas-incompatible operations during rendering ----@param callback function The callback to execute -function flexlove.deferCallback(callback) - if type(callback) ~= "function" then - flexlove._ErrorHandler:warn("FlexLove", "CORE_001") - return - end - table.insert(flexlove._deferredCallbacks, callback) -end - ---- Execute deferred operations at the safest point in the render cycle - after all canvas operations are complete ---- Call this at the end of love.draw() to enable window resizing and other state-modifying operations without crashes ---- @usage ---- function love.draw() ---- love.graphics.setCanvas(myCanvas) ---- FlexLove.draw() ---- love.graphics.setCanvas() -- Release ALL canvases ---- FlexLove.executeDeferredCallbacks() -- Now safe to execute ---- end -function flexlove.executeDeferredCallbacks() - if #flexlove._deferredCallbacks == 0 then - return - end - - -- Copy callbacks and clear queue before execution - -- This prevents infinite loops if callbacks defer more callbacks - local callbacks = flexlove._deferredCallbacks - flexlove._deferredCallbacks = {} - - for _, callback in ipairs(callbacks) do - local success, err = xpcall(callback, debug.traceback) - if not success then - flexlove._ErrorHandler:warn("FlexLove", "CORE_002", { - error = tostring(err), - }) - end - end -end - ---- Recalculate all UI layouts when the window size changes - ensures your interface adapts seamlessly to new dimensions ---- Hook this to love.resize() to maintain proper scaling and positioning across window size changes -function flexlove.resize() - local newWidth, newHeight = love.window.getMode() - - if flexlove.baseScale then - flexlove.scaleFactors.x = newWidth / flexlove.baseScale.width - flexlove.scaleFactors.y = newHeight / flexlove.baseScale.height - end - - if ModuleLoader.isModuleLoaded(modulePath .. "modules.Blur") then - Blur.clearCache() - end - - -- Release old canvases explicitly - if flexlove._gameCanvas then - flexlove._gameCanvas:release() - end - if flexlove._backdropCanvas then - flexlove._backdropCanvas:release() - end - - flexlove._gameCanvas = nil - flexlove._backdropCanvas = nil - flexlove._canvasDimensions = { width = 0, height = 0 } - - for _, win in ipairs(flexlove.topElements) do - win:resize(newWidth, newHeight) - end -end - ---- Switch between immediate mode (React-like, recreates UI each frame) and retained mode (persistent elements) to match your architectural needs ---- Use immediate for simpler state management and declarative UIs, retained for performance-critical applications with complex state ----@param mode "immediate"|"retained" -function flexlove.setMode(mode) - if mode == "immediate" then - flexlove._immediateMode = true - flexlove._immediateModeState = StateManager - flexlove._frameStarted = false - flexlove._autoBeganFrame = false - -- Notify StateManager of mode change - StateManager.setImmediateMode(true) - elseif mode == "retained" then - flexlove._immediateMode = false - flexlove._immediateModeState = nil - flexlove._frameStarted = false - flexlove._autoBeganFrame = false - flexlove._currentFrameElements = {} - flexlove._frameNumber = 0 - -- Notify StateManager of mode change - StateManager.setImmediateMode(false) - else - error("[FlexLove] Invalid mode: " .. tostring(mode) .. ". Expected 'immediate' or 'retained'") - end -end - ---- Check which rendering mode is active to conditionally handle state management logic ---- Useful for libraries and reusable components that need to adapt to different rendering strategies ----@return "immediate"|"retained" -function flexlove.getMode() - return flexlove._immediateMode and "immediate" or "retained" -end - ---- Manually start a new frame in immediate mode for precise control over the UI lifecycle ---- Only needed when you want explicit frame boundaries; otherwise FlexLove auto-manages frames -function flexlove.beginFrame() - if not flexlove._immediateMode then - return - end - - -- Reset accumulated delta time for new frame - flexlove._accumulatedDt = 0 - - -- Start performance frame timing - if flexlove._Performance then - flexlove._Performance:startFrame() - end - - -- Cleanup elements from PREVIOUS frame (after they've been drawn) - -- This breaks circular references and allows GC to collect memory - if flexlove._currentFrameElements then - local function cleanupChildren(elem) - for _, child in ipairs(elem.children) do - cleanupChildren(child) - end - elem:_cleanup() - end - - for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then - cleanupChildren(element) - end - end - end - - flexlove._frameNumber = flexlove._frameNumber + 1 - StateManager.incrementFrame() - flexlove._currentFrameElements = {} - flexlove._frameStarted = true - flexlove.topElements = {} - - Context.clearFrameElements() -end - ---- Finalize the frame in immediate mode, triggering layout calculations and state persistence ---- Only needed when manually controlling frames with beginFrame(); otherwise handled automatically -function flexlove.endFrame() - if not flexlove._immediateMode then - return - end - - Context.sortElementsByZIndex() - - -- Layout all top-level elements now that all children have been added - for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then - element:layoutChildren() - end - end - - flexlove._handleSelectPointerDismissal() - - -- Update all top-level elements created this frame - for _, element in ipairs(flexlove._currentFrameElements) do - if not element.parent then - element:update(flexlove._accumulatedDt) - end - end - - -- Save state for all elements created this frame - for _, element in ipairs(flexlove._currentFrameElements) do - if element.id and element.id ~= "" then - local stateUpdate = element:saveState() - local stateChanged = StateManager.updateStateIfChanged(element.id, stateUpdate) - if stateChanged and (element.backdropBlur or element.contentBlur) and Blur then - Blur.clearElementCache(element.id) - end - end - end - - StateManager.cleanup() - StateManager.forceCleanupIfNeeded() - -- Flush dirty state from this frame (no-op in retained mode) - StateManager.flushFrame() - flexlove._frameStarted = false - - -- End performance frame timing - if flexlove._Performance then - flexlove._Performance:endFrame() - flexlove._Performance:resetFrameCounters() - end -end - ----@type love.Canvas? -flexlove._gameCanvas = nil ----@type love.Canvas? -flexlove._backdropCanvas = nil ----@type {width: number, height: number} -flexlove._canvasDimensions = { width = 0, height = 0 } - ---- Recursively draw debug boundaries for an element and all its children ---- Draws regardless of visibility/opacity to reveal hidden or transparent elements ----@param element Element -local function drawDebugElement(element) - local color = element._debugColor - if color then - 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) - - -- Fill with 0.5 opacity - love.graphics.setColor(color[1], color[2], color[3], 0.5) - love.graphics.rectangle("fill", element.x, element.y, bw, bh) - - -- Border with full opacity, 1px line - love.graphics.setColor(color[1], color[2], color[3], 1) - love.graphics.setLineWidth(1) - love.graphics.rectangle("line", element.x, element.y, bw, bh) - end - - for _, child in ipairs(element.children) do - drawDebugElement(child) - end -end - ---- Render the debug draw overlay for all elements in the tree ---- Traverses every element regardless of visibility or opacity -function flexlove._renderDebugOverlay() - -- Save current graphics state - local prevR, prevG, prevB, prevA = love.graphics.getColor() - local prevLineWidth = love.graphics.getLineWidth() - - -- Clear any active scissor so debug draws are always visible - love.graphics.setScissor() - - for _, win in ipairs(flexlove.topElements) do - drawDebugElement(win) - end - - -- Restore graphics state - love.graphics.setColor(prevR, prevG, prevB, prevA) - love.graphics.setLineWidth(prevLineWidth) -end - ---- Render all UI elements with optional backdrop blur support for glassmorphic effects ---- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays ----@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur ----@param postDrawFunc function|nil pass component draws that should NOT be affected by a backdrop blur -function flexlove.draw(gameDrawFunc, postDrawFunc) - if flexlove._immediateMode and flexlove._autoBeganFrame then - flexlove.endFrame() - flexlove._autoBeganFrame = false - end - - local outerCanvas = love.graphics.getCanvas() - local gameCanvas = nil - - if type(gameDrawFunc) == "function" then - local width, height = love.graphics.getDimensions() - - if - not flexlove._gameCanvas - or flexlove._canvasDimensions.width ~= width - or flexlove._canvasDimensions.height ~= height - then - -- Release old canvases before creating new ones - if flexlove._gameCanvas then - flexlove._gameCanvas:release() - end - if flexlove._backdropCanvas then - flexlove._backdropCanvas:release() - end - - flexlove._gameCanvas = love.graphics.newCanvas(width, height) - flexlove._backdropCanvas = love.graphics.newCanvas(width, height) - flexlove._canvasDimensions.width = width - flexlove._canvasDimensions.height = height - end - - gameCanvas = flexlove._gameCanvas - - love.graphics.setCanvas(gameCanvas) - love.graphics.clear() - gameDrawFunc() - love.graphics.setCanvas(outerCanvas) - - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(gameCanvas, 0, 0) - end - - table.sort(flexlove.topElements, function(a, b) - return a.z < b.z - end) - - local function hasBackdropBlur(element) - if element.backdropBlur and element.backdropBlur.radius > 0 then - return true - end - for _, child in ipairs(element.children) do - if hasBackdropBlur(child) then - return true - end - end - return false - end - - local needsBackdropCanvas = false - for _, win in ipairs(flexlove.topElements) do - if hasBackdropBlur(win) then - needsBackdropCanvas = true - break - end - end - - if needsBackdropCanvas and gameCanvas then - local backdropCanvas = flexlove._backdropCanvas - local prevColor = { love.graphics.getColor() } - - love.graphics.setCanvas(backdropCanvas) - love.graphics.clear() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(gameCanvas, 0, 0) - - love.graphics.setCanvas(outerCanvas) - love.graphics.setColor(unpack(prevColor)) - - for _, win in ipairs(flexlove.topElements) do - -- Check if this element tree has backdrop blur - local needsBackdrop = hasBackdropBlur(win) - - -- Draw element with backdrop blur applied if needed - if needsBackdrop then - win:draw(backdropCanvas) - else - win:draw(nil) - end - - -- IMPORTANT: Update backdrop canvas for EVERY element (respecting z-index order) - -- This ensures that lower z-index elements are visible in the backdrop blur - -- of higher z-index elements - love.graphics.setCanvas(backdropCanvas) - love.graphics.setColor(1, 1, 1, 1) - win:draw(nil) - love.graphics.setCanvas(outerCanvas) - end - else - for _, win in ipairs(flexlove.topElements) do - win:draw(nil) - end - end - - if type(postDrawFunc) == "function" then - postDrawFunc() - end - - -- Render performance HUD if enabled - if flexlove._Performance then - flexlove._Performance:renderHUD() - end - - -- Render focus indicator if keyboard navigation is enabled - if KeyboardNavigation and KeyboardNavigation.config and KeyboardNavigation.config.enabled and FocusIndicator then - FocusIndicator:draw() - end - - -- Render debug draw overlay if enabled - if flexlove._debugDraw then - flexlove._renderDebugOverlay() - end - - love.graphics.setCanvas(outerCanvas) - - -- NOTE: Deferred callbacks are NOT executed here because the calling code - -- (e.g., main.lua) might still have a canvas active. Callbacks must be - -- executed by calling FlexLove.executeDeferredCallbacks() at the very end - -- of love.draw() after ALL canvases have been released. -end - ---- Check if element is an ancestor of target ----@param element Element The potential ancestor element ----@param target Element The target element to check ----@return boolean isAncestor True if element is an ancestor of target -local function isAncestor(element, target) - local current = target.parent - while current do - if current == element then - return true - end - current = current.parent - end - return false -end - ----@param element Element ----@param results Element[] -local function collectOpenSelects(element, results) - if element._selectState and element._selectState.open then - table.insert(results, element) - end - - for _, child in ipairs(element.children) do - collectOpenSelects(child, results) - end -end - -function flexlove._handleSelectPointerDismissal() - local isLeftDown = love.mouse.isDown(1) - local wasLeftDown = flexlove._mouseButtonStates[1] or false - - if isLeftDown and not wasLeftDown then - local mx, my = love.mouse.getPosition() - local target = flexlove.getElementAtPosition(mx, my) - local openSelects = {} - - for _, element in ipairs(flexlove.topElements) do - collectOpenSelects(element, openSelects) - end - - for _, selectParent in ipairs(openSelects) do - local containsTarget = target and (target == selectParent or isAncestor(selectParent, target)) - if not containsTarget then - selectParent:closeSelect() - end - end - end - - flexlove._mouseButtonStates[1] = isLeftDown -end - ---- Determine which UI element the user is interacting with at a specific screen position ---- Essential for custom input handling, tooltips, or debugging click targets in complex layouts ----@param x number ----@param y number ----@return Element? -function flexlove.getElementAtPosition(x, y) - local candidates = {} - local blockingElements = {} - - local function collectHits(element, scrollOffsetX, scrollOffsetY) - scrollOffsetX = scrollOffsetX or 0 - scrollOffsetY = scrollOffsetY or 0 - - -- pointHitsElement is the single canonical bounds + display:none guard. - if Context.pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then - -- Skip invisible/transparent elements and their entire subtree - if element.visibility == "hidden" or element.opacity <= 0 then - return - end - - -- Collect interactive elements (those with onEvent handlers) - if - (element.onEvent or element.editable or element._selectState or element.selectOption) and not element.disabled - then - table.insert(candidates, element) - end - - -- Collect all visible elements for input blocking - -- Elements with opacity > 0 block input to elements below them - if element.opacity > 0 then - table.insert(blockingElements, element) - end - - -- Check if this element has scrollable overflow - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - local hasScrollableOverflow = ( - overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - or overflowX == "hidden" - or overflowY == "hidden" - ) - - -- Accumulate scroll offset for children if this element has overflow clipping - local childScrollOffsetX = scrollOffsetX - local childScrollOffsetY = scrollOffsetY - if hasScrollableOverflow then - childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) - childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) - end - - for _, child in ipairs(element.children) do - collectHits(child, childScrollOffsetX, childScrollOffsetY) - end - end - end - - for _, element in ipairs(flexlove.topElements) do - collectHits(element) - end - - -- Sort both lists by composite z-index (highest first). Uses the same - -- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ key as - -- Context.sortElementsByZIndex / findInteractiveAtPosition so the hit-test - -- topmost matches the visual draw order across overlapping windows. - -- Skip the precompute + sort when there is 0 or 1 element (the common case - -- for getElementAtPosition which is called on every love.mousemoved event). - if #candidates > 1 then - local candidateZ = {} - for i = 1, #candidates do - candidateZ[candidates[i]] = Context.getEffectiveZIndex(candidates[i]) - end - table.sort(candidates, function(a, b) - return candidateZ[a] > candidateZ[b] - end) - end - - if #blockingElements > 1 then - local blockerZ = {} - for i = 1, #blockingElements do - blockerZ[blockingElements[i]] = Context.getEffectiveZIndex(blockingElements[i]) - end - table.sort(blockingElements, function(a, b) - return blockerZ[a] > blockerZ[b] - end) - end - - -- If we have interactive elements, return the topmost one - -- But only if there's no blocking element with higher z-index (that isn't an ancestor) - if #candidates > 0 then - local topCandidate = candidates[1] - - -- Check if any blocking element would prevent this interaction - if #blockingElements > 0 then - local topBlocker = blockingElements[1] - -- If the top blocker has higher z-index than the top candidate, - -- and the blocker is NOT an ancestor of the candidate, - -- return the blocker (even though it has no onEvent, it blocks input) - if topBlocker.z > topCandidate.z and not isAncestor(topBlocker, topCandidate) then - return topBlocker - end - end - - return topCandidate - end - - -- No interactive elements, but return topmost blocking element if any - -- This prevents clicks from passing through non-interactive overlays - return blockingElements[1] -end - ---- Update all UI animations, interactions, and state changes each frame ---- Hook this to love.update() to enable hover effects, animations, text cursors, and scrolling ----@param dt number -function flexlove.update(dt) - -- Update Performance module with actual delta time for accurate FPS - if flexlove._Performance then - flexlove._Performance:updateDeltaTime(dt) - end - - -- Update keyboard navigation (animations, etc.) - if KeyboardNavigation then - KeyboardNavigation:update(dt) - end - - -- Garbage collection management - flexlove._manageGC() - - -- Invalidate the per-frame findInteractiveAtPosition cache so Clickable's - -- per-element occlusion lookup (one per interactive element per frame) is - -- recomputed fresh for this frame's tree. Within the frame every - -- Clickable.onUpdate then shares one cached result instead of re-walking - -- + re-sorting the tree per element (unified-event-routing task 05 fix). - flexlove.clearInteractiveCache() - - -- Select-pointer dismissal: if the left mouse button was just pressed, - -- check whether any open Select dropdowns should be closed (click-outside). - -- This calls getElementAtPosition ONLY on the click frame, not every frame. - flexlove._handleSelectPointerDismissal() - - -- In immediate mode, accumulate dt and skip updating here - elements will be updated in endFrame after layout - if flexlove._immediateMode then - flexlove._accumulatedDt = flexlove._accumulatedDt + dt - else - for _, win in ipairs(flexlove.topElements) do - win:update(dt) - 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) -function flexlove._manageGC() - local strategy = flexlove._gcConfig.strategy - - if strategy == "disabled" then - return - end - - local currentMemory = collectgarbage("count") / 1024 -- Convert to MB - flexlove._gcState.lastMemory = currentMemory - flexlove._gcState.framesSinceLastGC = flexlove._gcState.framesSinceLastGC + 1 - - -- Check memory threshold (applies to all strategies except disabled) - if currentMemory > flexlove._gcConfig.memoryThreshold then - -- Force full GC when exceeding threshold - collectgarbage("collect") - flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 - flexlove._gcState.framesSinceLastGC = 0 - return - end - - -- Strategy-specific GC - if strategy == "periodic" then - -- Run incremental GC step every N frames - if flexlove._gcState.framesSinceLastGC >= flexlove._gcConfig.interval then - collectgarbage("step", flexlove._gcConfig.stepSize) - flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 - flexlove._gcState.framesSinceLastGC = 0 - end - elseif strategy == "auto" then - -- Let Lua's automatic GC handle it, but help with incremental steps - -- Run a small step every frame to keep memory under control - if flexlove._gcState.framesSinceLastGC >= 5 then - collectgarbage("step", 50) -- Small steps to avoid frame drops - flexlove._gcState.framesSinceLastGC = 0 - end - end - -- "manual" strategy: no automatic GC, user must call flexlove.collectGarbage() -end - ---- Manually trigger garbage collection to prevent frame drops during critical gameplay moments ---- Use this to control when memory cleanup happens rather than letting it occur unpredictably ----@param mode? string "collect" for full GC, "step" for incremental (default: "collect") ----@param stepSize? number Work units for step mode (default: 200) -function flexlove.collectGarbage(mode, stepSize) - mode = mode or "collect" - stepSize = stepSize or 200 - - if mode == "collect" then - collectgarbage("collect") - flexlove._gcState.gcCount = flexlove._gcState.gcCount + 1 - flexlove._gcState.framesSinceLastGC = 0 - elseif mode == "step" then - collectgarbage("step", stepSize) - elseif mode == "count" then - return collectgarbage("count") / 1024 -- Return memory in MB - end -end - ---- Choose how FlexLove manages memory cleanup to balance performance and memory usage for your app's needs ---- Use "manual" for tight control in performance-critical sections, "auto" for hands-off operation ----@param strategy string "auto", "periodic", "manual", or "disabled" -function flexlove.setGCStrategy(strategy) - if strategy == "auto" or strategy == "periodic" or strategy == "manual" or strategy == "disabled" then - flexlove._gcConfig.strategy = strategy - else - flexlove._ErrorHandler:warn("FlexLove", "CORE_003", { - strategy = tostring(strategy), - }) - end -end - ---- Monitor memory management behavior to diagnose performance issues and tune GC settings ---- Use this to identify memory leaks or optimize garbage collection timing ----@return GCStats stats GC statistics -function flexlove.getGCStats() - return { - gcCount = flexlove._gcState.gcCount, - framesSinceLastGC = flexlove._gcState.framesSinceLastGC, - currentMemoryMB = flexlove._gcState.lastMemory, - strategy = flexlove._gcConfig.strategy, - threshold = flexlove._gcConfig.memoryThreshold, - } -end - ---- Forward text input to focused editable elements like text fields and text areas ---- Hook this to love.textinput() to enable text entry in your UI ----@param text string -function flexlove.textinput(text) - local focusedElement = Context.getFocused() - if focusedElement and not focusedElement.disabled then - focusedElement:textinput(text) - end -end - ---- Handle keyboard input for text editing, navigation, and performance overlay toggling ---- Hook this to love.keypressed() to enable text selection, cursor movement, and the performance HUD ----@param key string ----@param scancode string ----@param isrepeat boolean -function flexlove.keypressed(key, scancode, isrepeat) - if flexlove._Performance then - flexlove._Performance:keypressed(key) - end - if flexlove._debugDrawKey and key == flexlove._debugDrawKey then - flexlove._debugDraw = not flexlove._debugDraw - end - - -- Handle keyboard navigation (if module is available and enabled) - if KeyboardNavigation and KeyboardNavigation.config and KeyboardNavigation.config.enabled then - -- Debug logging for keyboard navigation entry point - if KeyboardNavigation.config.debugMode then - print(string.format("[FlexLove.keypressed] Keyboard nav enabled, handling key: %s", key)) - end - - -- Check if we're in text input mode (editable element focused) - local focusedElement = Context.getFocused() - local isTextInputMode = focusedElement and (focusedElement.editable or focusedElement._textEditor) - - -- Only handle navigation if not in text input mode, or if in text input mode without modifiers - local shouldHandleNav = not isTextInputMode - or ( - isTextInputMode - and not ( - love.keyboard.isDown("lctrl") - or love.keyboard.isDown("rctrl") - or love.keyboard.isDown("lalt") - or love.keyboard.isDown("ralt") - ) - ) - - if shouldHandleNav then - local handled = KeyboardNavigation:handleKeyPress(key, scancode, isrepeat) - if KeyboardNavigation.config.debugMode and not handled then - print(string.format("[FlexLove.keypressed] Key %s was NOT handled by keyboard navigation", key)) - end - if handled then - return -- Navigation handled the key, don't forward to element - end - end - end - - -- Forward to focused element for text input - local focusedElement = Context.getFocused() - if focusedElement and not focusedElement.disabled then - focusedElement:keypressed(key, scancode, isrepeat) - end -end - ---- Enable mouse wheel scrolling in scrollable containers and lists ---- Hook this to love.wheelmoved() to allow users to scroll through content naturally ----@param dx number ----@param dy number -function flexlove.wheelmoved(dx, dy) - local mx, my = love.mouse.getPosition() - local element = Context.findScrollableAtPosition(mx, my) - - if element then - element:_handleWheelScroll(dx, dy) - - -- In immediate mode, persist scroll manager state for next frame - if flexlove._immediateMode and element._stateId and element._scrollManager then - local scrollManagerState = element._scrollManager:getState() - StateManager.updateState(element._stateId, { - scrollManager = scrollManagerState, - }) - end - end -end - ---- Find the touch-interactive element at a given position using z-index ordering ---- Similar to getElementAtPosition but checks for touch-enabled elements ----@param x number Touch X position ----@param y number Touch Y position ----@return Element|nil element The topmost touch-enabled element at position -function flexlove._getTouchElementAtPosition(x, y) - local candidates = {} - - local function collectTouchHits(element, scrollOffsetX, scrollOffsetY) - scrollOffsetX = scrollOffsetX or 0 - scrollOffsetY = scrollOffsetY or 0 - - -- pointHitsElement is the single canonical bounds + display:none guard. - if Context.pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then - -- Check if element is touch-enabled and interactive - if - element.touchEnabled - and not element.disabled - and (element.onEvent or element.onTouchEvent or element.onGesture) - then - table.insert(candidates, element) - end - - -- Check if this element has scrollable overflow (for touch scrolling) - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - local hasScrollableOverflow = ( - overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - or overflowX == "hidden" - or overflowY == "hidden" - ) - - -- Accumulate scroll offset for children - local childScrollOffsetX = scrollOffsetX - local childScrollOffsetY = scrollOffsetY - if hasScrollableOverflow then - childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) - childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) - end - - for _, child in ipairs(element.children) do - collectTouchHits(child, childScrollOffsetX, childScrollOffsetY) - end - end - end - - for _, element in ipairs(flexlove.topElements) do - collectTouchHits(element) - end - - -- Sort by z-index (highest first) — topmost element wins - table.sort(candidates, function(a, b) - return a.z > b.z - end) - - return candidates[1] -end - -local function elementIsTouchScrollable(element) - if not element or not element._scrollManager then - return false - end - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - return overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" -end - --- Walk parents from a hit target so a finger on a button/row still scrolls --- the containing list. Falls back to the wheel path's scrollable lookup when --- the press lands on empty space inside a scroller. -local function findTouchScrollTarget(x, y, startElement) - local el = startElement - while el do - if elementIsTouchScrollable(el) then - return el - end - el = el.parent - end - return Context.findScrollableAtPosition(x, y) -end - -local function findElementByStateId(stateId) - if not stateId or stateId == "" then - return nil - end - local function walk(element) - if element.id == stateId or element._stateId == stateId then - return element - end - for _, child in ipairs(element.children or {}) do - local found = walk(child) - if found then - return found - end - end - return nil - end - for _, element in ipairs(flexlove.topElements or {}) do - local found = walk(element) - if found then - return found - end - end - for _, element in ipairs(flexlove._currentFrameElements or {}) do - local found = walk(element) - if found then - return found - end - end - return nil -end - -local function persistTouchScroll(element) - if flexlove._immediateMode and element and element._stateId and element._scrollManager then - StateManager.updateState(element._stateId, { - scrollManager = element._scrollManager:getState(), - }) - end -end - -local function resolveTouchScrollElement(track) - if not track then - return nil - end - local element = findElementByStateId(track.id) - if elementIsTouchScrollable(element) then - return element - end - return nil -end - ---- Handle touch press events from LÖVE's touch input system ---- Routes touch to the topmost element at the touch position and assigns touch ownership ---- Hook this to love.touchpressed() to enable touch interaction ----@param id lightuserdata Touch identifier from LÖVE ----@param x number Touch X position in screen coordinates ----@param y number Touch Y position in screen coordinates ----@param dx number X distance moved (usually 0 on press) ----@param dy number Y distance moved (usually 0 on press) ----@param pressure number Touch pressure (0-1, if supported by device) -function flexlove.touchpressed(id, x, y, dx, dy, pressure) - local touchId = tostring(id) - pressure = pressure or 1.0 - - -- Apply base scaling if configured - local touchX, touchY = x, y - if flexlove.baseScale then - touchX = x / flexlove.scaleFactors.x - touchY = y / flexlove.scaleFactors.y - end - - -- Find the topmost touch-enabled element at this position - local element = flexlove._getTouchElementAtPosition(touchX, touchY) - - if element then - -- Assign touch ownership: this element receives all subsequent events for this touch - flexlove._touchOwners[touchId] = element - - -- Create and route touch event - local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "began", pressure) - element:handleTouchEvent(touchEvent) - - -- Feed to shared gesture recognizer - if flexlove._gestureRecognizer then - local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) - if gestures then - for _, gesture in ipairs(gestures) do - element:handleGesture(gesture) - end - end - end - end - - -- Scroll target is the nearest scrollable ancestor (or the scroller under - -- empty space). Tracked by stable id so immediate-mode recreation can resume. - local scrollEl = findTouchScrollTarget(touchX, touchY, element) - if scrollEl and scrollEl._scrollManager then - local scrollId = scrollEl._stateId or scrollEl.id - if scrollId and scrollId ~= "" then - flexlove._touchScroll[touchId] = { - id = scrollId, - lastX = touchX, - lastY = touchY, - } - end - scrollEl._scrollManager:handleTouchPress(touchX, touchY) - persistTouchScroll(scrollEl) - end -end - ---- Handle touch move events from LÖVE's touch input system ---- Routes touch to the element that owns this touch ID (from the original press), regardless of current position ---- Hook this to love.touchmoved() to enable touch drag and gesture tracking ----@param id lightuserdata Touch identifier from LÖVE ----@param x number Touch X position in screen coordinates ----@param y number Touch Y position in screen coordinates ----@param dx number X distance moved since last event ----@param dy number Y distance moved since last event ----@param pressure number Touch pressure (0-1, if supported by device) -function flexlove.touchmoved(id, x, y, dx, dy, pressure) - local touchId = tostring(id) - pressure = pressure or 1.0 - - -- Apply base scaling if configured - local touchX, touchY = x, y - if flexlove.baseScale then - touchX = x / flexlove.scaleFactors.x - touchY = y / flexlove.scaleFactors.y - end - - -- Route to owning element (touch ownership persists from press to release) - local element = flexlove._touchOwners[touchId] - if element then - -- Create and route touch event - local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "moved", pressure) - element:handleTouchEvent(touchEvent) - - -- Feed to shared gesture recognizer - if flexlove._gestureRecognizer then - local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) - if gestures then - for _, gesture in ipairs(gestures) do - element:handleGesture(gesture) - end - end - end - end - - local track = flexlove._touchScroll[touchId] - local scrollEl = resolveTouchScrollElement(track) - if track and scrollEl then - local sm = scrollEl._scrollManager - -- Immediate mode recreates managers each frame; re-arm drag from the - -- last persisted touch point so a move after beginFrame still scrolls. - if not sm._touchScrolling then - sm:handleTouchPress(track.lastX, track.lastY) - end - sm:handleTouchMove(touchX, touchY) - persistTouchScroll(scrollEl) - track.lastX = touchX - track.lastY = touchY - end -end - ---- Handle touch release events from LÖVE's touch input system ---- Routes touch to the owning element and cleans up touch ownership tracking ---- Hook this to love.touchreleased() to properly end touch interactions ----@param id lightuserdata Touch identifier from LÖVE ----@param x number Touch X position in screen coordinates ----@param y number Touch Y position in screen coordinates ----@param dx number X distance moved since last event ----@param dy number Y distance moved since last event ----@param pressure number Touch pressure (0-1, if supported by device) -function flexlove.touchreleased(id, x, y, dx, dy, pressure) - local touchId = tostring(id) - pressure = pressure or 1.0 - - -- Apply base scaling if configured - local touchX, touchY = x, y - if flexlove.baseScale then - touchX = x / flexlove.scaleFactors.x - touchY = y / flexlove.scaleFactors.y - end - - -- Route to owning element - local element = flexlove._touchOwners[touchId] - if element then - -- Create and route touch event - local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "ended", pressure) - element:handleTouchEvent(touchEvent) - - -- Feed to shared gesture recognizer - if flexlove._gestureRecognizer then - local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent) - if gestures then - for _, gesture in ipairs(gestures) do - element:handleGesture(gesture) - end - end - end - end - - local track = flexlove._touchScroll[touchId] - local scrollEl = resolveTouchScrollElement(track) - if track and scrollEl then - local sm = scrollEl._scrollManager - if not sm._touchScrolling then - sm:handleTouchPress(track.lastX, track.lastY) - end - sm:handleTouchMove(touchX, touchY) - sm:handleTouchRelease() - persistTouchScroll(scrollEl) - end - - -- Clean up touch ownership (touch is complete) - flexlove._touchOwners[touchId] = nil - flexlove._touchScroll[touchId] = nil -end - ---- Get the number of currently active touches being tracked ----@return number count Number of active touch points -function flexlove.getActiveTouchCount() - local count = 0 - for _ in pairs(flexlove._touchOwners) do - count = count + 1 - end - return count -end - ---- Get the element that currently owns a specific touch ----@param touchId string|lightuserdata Touch identifier ----@return Element|nil element The element owning this touch, or nil -function flexlove.getTouchOwner(touchId) - return flexlove._touchOwners[tostring(touchId)] -end - ---- Retrieve an element by its ID from the UI tree ---- Works in both immediate and retained modes; searches all known elements including top-level and nested children ----@param id string The element ID to search for ----@return Element|nil element The found element, or nil if not found -function flexlove.getById(id) - if not id or id == "" then - return nil - end - - local function findElementById(element, targetId) - if element.id == targetId then - return element - end - - for _, child in ipairs(element.children) do - local result = findElementById(child, targetId) - if result then - return result - end - end - - return nil - end - - for _, win in ipairs(flexlove.topElements) do - local result = findElementById(win, id) - if result then - return result - end - end - - if flexlove._currentFrameElements then - for _, element in ipairs(flexlove._currentFrameElements) do - local result = findElementById(element, id) - if result then - return result - end - end - end - - if Context._zIndexOrderedElements then - for _, element in ipairs(Context._zIndexOrderedElements) do - local result = findElementById(element, id) - if result then - return result - end - end - end - - return nil -end - ---- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down ---- Use this to prevent memory leaks when transitioning between game states or menus -function flexlove.destroy() - for _, win in ipairs(flexlove.topElements) do - win:destroy() - end - flexlove.topElements = {} - flexlove.baseScale = nil - flexlove.scaleFactors = { x = 1.0, y = 1.0 } - flexlove._cachedViewport = { width = 0, height = 0 } - - -- Release canvases explicitly before destroying - if flexlove._gameCanvas then - flexlove._gameCanvas:release() - end - if flexlove._backdropCanvas then - flexlove._backdropCanvas:release() - end - - flexlove._gameCanvas = nil - flexlove._backdropCanvas = nil - flexlove._canvasDimensions = { width = 0, height = 0 } - Context.clearFocus() - StateManager:reset() - - -- Clean up touch state - flexlove._touchOwners = {} - flexlove._touchScroll = {} - flexlove._mouseButtonStates = {} - if flexlove._gestureRecognizer then - flexlove._gestureRecognizer:reset() - end -end - ---- Create a new UI element with flexbox layout, styling, and interaction capabilities ---- This is your primary API for building interfaces - buttons, panels, text, images, and containers ---- If called before FlexLove.init(), the element creation will be automatically queued and executed after initialization ----@param props ElementProps ----@param callback? function Optional callback function(element) that will be called with the created element (useful when queued) ----@return Element -- Returns element if initialized, nil if queued for later creation -function flexlove.new(props, callback) - props = props or {} - - if not flexlove.initialized then - -- Queue element creation for after initialization - table.insert(flexlove._initQueue, { - props = props, - callback = callback, - }) - - if flexlove._initState == "uninitialized" then - if flexlove._ErrorHandler then - flexlove._ErrorHandler:warn( - "FlexLove", - "[FlexLove] Element creation queued - FlexLove.init() has not been called yet. Element will be created automatically after init() is called." - ) - end - end - return nil - end - - -- Use global mode to determine behavior - if not flexlove._immediateMode then - return Element.new(props) - end - - -- Immediate mode - proceed with immediate-mode logic - -- Auto-begin frame if not manually started (convenience feature) - if not flexlove._frameStarted then - flexlove.beginFrame() - flexlove._autoBeganFrame = true - end - - -- Immediate mode: generate ID if not provided - if not props.id then - props.id = StateManager.generateID(props, props.parent) - end - - -- Get or create state for this element - local state = StateManager.getState(props.id, {}) - - -- Mark state as used this frame - StateManager.markStateUsed(props.id) - - -- Inject scroll state into props BEFORE creating element - -- This ensures scroll position is set before layoutChildren/detectOverflow is called - -- 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) - - -- Restore all state from StateManager (delegates to sub-modules) - element:restoreState(state) - - -- Bind element to StateManager for interactive states - element._stateId = props.id - - -- 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 - local eventState = state.eventHandler or {} - if element.disabled or eventState.disabled then - element._themeState = "disabled" - elseif element.active or eventState.active then - element._themeState = "active" - elseif eventState._pressed and next(eventState._pressed) then - element._themeState = "pressed" - elseif eventState._hovered then - element._themeState = "hover" - else - element._themeState = "normal" - end - end - - table.insert(flexlove._currentFrameElements, element) - - return element -end - ---- Check how many UI element states are being tracked in immediate mode to detect memory leaks ---- Use this during development to ensure states are properly cleaned up ----@return number -function flexlove.getStateCount() - if not flexlove._immediateMode then - return 0 - end - return StateManager.getStateCount() -end - ---- Remove stored state for a specific element when you know it won't be rendered again ---- Use this to immediately free memory for elements you've removed from your UI ----@param id string -function flexlove.clearState(id) - if not flexlove._immediateMode then - return - end - StateManager.clearState(id) -end - ---- Wipe all element state when transitioning between completely different UI screens ---- Use this for scene transitions to start with a clean slate and prevent state pollution -function flexlove.clearAllStates() - if not flexlove._immediateMode then - return - end - StateManager.clearAllStates() -end - ---- Inspect state management metrics to diagnose performance issues and optimize immediate mode usage ---- Use this to understand state lifecycle and identify unexpected state accumulation ----@return { stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil } -function flexlove.getStateStats() - if not flexlove._immediateMode then - return { stateCount = 0, frameNumber = 0 } - end - return StateManager.getStats() -end - ---- Create a calc() expression for dynamic CSS-like calculations ---- Use this to create responsive layouts that adapt to viewport and parent dimensions ---- @usage ---- local button = FlexLove.new({ ---- x = FlexLove.calc("50% - 10vw"), ---- y = FlexLove.calc("50% - 5vh"), ---- width = "20vw", ---- height = "10vh", ---- }) ----@param expr string The calc expression (e.g., "50% - 10vw", "100px + 20%") ----@return CalcObject calcObject A calc expression object that will be evaluated during layout -function flexlove.calc(expr) - return Calc.new(expr) -end - ---- Get the currently focused element ---- Returns the element that is currently receiving keyboard input (e.g., text input, text area) ----@return Element|nil The focused element, or nil if no element has focus -function flexlove.getFocusedElement() - return Context.getFocused() -end - ---- Set focus to a specific element ---- Automatically blurs the previously focused element if different ---- Use this to programmatically focus text inputs or other interactive elements ----@param element Element|nil The element to focus (nil to clear focus) -function flexlove.setFocusedElement(element) - Context.setFocused(element) -end - ---- Clear focus from any element ---- Removes keyboard focus from the currently focused element -function flexlove.clearFocus() - Context.setFocused(nil) -end - ---- Enable or disable the debug draw overlay that renders element boundaries with random colors ---- Each element gets a unique color: full opacity border and 0.5 opacity fill to identify collisions and overlaps ----@param enabled boolean True to enable debug draw overlay, false to disable -function flexlove.setDebugDraw(enabled) - flexlove._debugDraw = enabled -end - ---- Check if the debug draw overlay is currently active ----@return boolean enabled True if debug draw overlay is enabled -function flexlove.getDebugDraw() - return flexlove._debugDraw -end - -flexlove.Animation = Animation -flexlove.Color = Color -flexlove.Theme = Theme -flexlove.enums = enums - -return flexlove diff --git a/libs/flexlove/LICENSE b/libs/flexlove/LICENSE deleted file mode 100644 index 0b2a1f3e..00000000 --- a/libs/flexlove/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Mike Freno - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/libs/flexlove/modules/Animation.lua b/libs/flexlove/modules/Animation.lua deleted file mode 100644 index 66a3d5d1..00000000 --- a/libs/flexlove/modules/Animation.lua +++ /dev/null @@ -1,1579 +0,0 @@ -local Easing = {} - ----@type EasingFunction -function Easing.linear(t) - return t -end - ----@type EasingFunction -function Easing.easeInQuad(t) - return t * t -end - ----@type EasingFunction -function Easing.easeOutQuad(t) - return t * (2 - t) -end - ----@type EasingFunction -function Easing.easeInOutQuad(t) - return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t -end - ----@type EasingFunction -function Easing.easeInCubic(t) - return t * t * t -end - ----@type EasingFunction -function Easing.easeOutCubic(t) - local t1 = t - 1 - return t1 * t1 * t1 + 1 -end - ----@type EasingFunction -function Easing.easeInOutCubic(t) - return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 -end - ----@type EasingFunction -function Easing.easeInQuart(t) - return t * t * t * t -end - ----@type EasingFunction -function Easing.easeOutQuart(t) - local t1 = t - 1 - return 1 - t1 * t1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutQuart(t) - if t < 0.5 then - return 8 * t * t * t * t - else - local t1 = t - 1 - return 1 - 8 * t1 * t1 * t1 * t1 - end -end - ----@type EasingFunction -function Easing.easeInQuint(t) - return t * t * t * t * t -end - ----@type EasingFunction -function Easing.easeOutQuint(t) - local t1 = t - 1 - return 1 + t1 * t1 * t1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutQuint(t) - if t < 0.5 then - return 16 * t * t * t * t * t - else - local t1 = t - 1 - return 1 + 16 * t1 * t1 * t1 * t1 * t1 - end -end - ----@type EasingFunction -function Easing.easeInExpo(t) - return t == 0 and 0 or math.pow(2, 10 * (t - 1)) -end - ----@type EasingFunction -function Easing.easeOutExpo(t) - return t == 1 and 1 or 1 - math.pow(2, -10 * t) -end - ----@type EasingFunction -function Easing.easeInOutExpo(t) - if t == 0 then - return 0 - end - if t == 1 then - return 1 - end - - if t < 0.5 then - return 0.5 * math.pow(2, 20 * t - 10) - else - return 1 - 0.5 * math.pow(2, -20 * t + 10) - end -end - ----@type EasingFunction -function Easing.easeInSine(t) - return 1 - math.cos(t * math.pi / 2) -end - ----@type EasingFunction -function Easing.easeOutSine(t) - return math.sin(t * math.pi / 2) -end - ----@type EasingFunction -function Easing.easeInOutSine(t) - return -(math.cos(math.pi * t) - 1) / 2 -end - ----@type EasingFunction -function Easing.easeInCirc(t) - return 1 - math.sqrt(1 - t * t) -end - ----@type EasingFunction -function Easing.easeOutCirc(t) - local t1 = t - 1 - return math.sqrt(1 - t1 * t1) -end - ----@type EasingFunction -function Easing.easeInOutCirc(t) - if t < 0.5 then - return (1 - math.sqrt(1 - 4 * t * t)) / 2 - else - local t1 = -2 * t + 2 - return (math.sqrt(1 - t1 * t1) + 1) / 2 - end -end - ----@type EasingFunction -function Easing.easeInBack(t) - local c1 = 1.70158 - local c3 = c1 + 1 - return c3 * t * t * t - c1 * t * t -end - ----@type EasingFunction -function Easing.easeOutBack(t) - local c1 = 1.70158 - local c3 = c1 + 1 - local t1 = t - 1 - return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutBack(t) - local c1 = 1.70158 - local c2 = c1 * 1.525 - - if t < 0.5 then - return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 - else - local t1 = 2 * t - 2 - return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 - end -end - ----@type EasingFunction -function Easing.easeInElastic(t) - if t == 0 then - return 0 - end - if t == 1 then - return 1 - end - - local c4 = (2 * math.pi) / 3 - return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) -end - ----@type EasingFunction -function Easing.easeOutElastic(t) - if t == 0 then - return 0 - end - if t == 1 then - return 1 - end - - local c4 = (2 * math.pi) / 3 - return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 -end - ----@type EasingFunction -function Easing.easeInOutElastic(t) - if t == 0 then - return 0 - end - if t == 1 then - return 1 - end - - local c5 = (2 * math.pi) / 4.5 - - if t < 0.5 then - return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 - else - return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 - end -end - ----@type EasingFunction -function Easing.easeOutBounce(t) - local n1 = 7.5625 - local d1 = 2.75 - - if t < 1 / d1 then - return n1 * t * t - elseif t < 2 / d1 then - local t1 = t - 1.5 / d1 - return n1 * t1 * t1 + 0.75 - elseif t < 2.5 / d1 then - local t1 = t - 2.25 / d1 - return n1 * t1 * t1 + 0.9375 - else - local t1 = t - 2.625 / d1 - return n1 * t1 * t1 + 0.984375 - end -end - ----@type EasingFunction -function Easing.easeInBounce(t) - return 1 - Easing.easeOutBounce(1 - t) -end - ----@type EasingFunction -function Easing.easeInOutBounce(t) - if t < 0.5 then - return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 - else - return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 - end -end - ---- Create a custom back easing function with configurable overshoot ----@param overshoot number? Overshoot amount (default: 1.70158) ----@return EasingFunction -function Easing.back(overshoot) - overshoot = overshoot or 1.70158 - local c3 = overshoot + 1 - - return function(t) - return c3 * t * t * t - overshoot * t * t - end -end - ---- Create a custom elastic easing function ----@param amplitude number? Amplitude (default: 1) ----@param period number? Period (default: 0.3) ----@return EasingFunction -function Easing.elastic(amplitude, period) - amplitude = amplitude or 1 - period = period or 0.3 - - return function(t) - if t == 0 then - return 0 - end - if t == 1 then - return 1 - end - - local s = period / 4 - local a = amplitude - - if a < 1 then - a = 1 - s = period / 4 - else - s = period / (2 * math.pi) * math.asin(1 / a) - end - - return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 - end -end - --- ============================================================================ --- TRANSFORM --- ============================================================================ - -local Transform = {} -Transform.__index = Transform - ---- Create a new transform instance ----@param props Transform? ----@return Transform transform -function Transform.new(props) - props = props or {} - - local self = setmetatable({}, Transform) - - self.rotate = props.rotate or 0 - self.scaleX = props.scaleX or 1 - self.scaleY = props.scaleY or 1 - self.translateX = props.translateX or 0 - self.translateY = props.translateY or 0 - self.skewX = props.skewX or 0 - self.skewY = props.skewY or 0 - self.originX = props.originX or 0.5 - self.originY = props.originY or 0.5 - - return self -end - ---- Apply transform to LÖVE graphics context ----@param transform Transform Transform instance ----@param x number Element x position ----@param y number Element y position ----@param width number Element width ----@param height number Element height -function Transform.apply(transform, x, y, width, height) - if not transform then - return - end - - local ox = x + width * transform.originX - local oy = y + height * transform.originY - - love.graphics.push() - love.graphics.translate(ox, oy) - - if transform.rotate ~= 0 then - love.graphics.rotate(transform.rotate) - end - - if transform.scaleX ~= 1 or transform.scaleY ~= 1 then - love.graphics.scale(transform.scaleX, transform.scaleY) - end - - if transform.skewX ~= 0 or transform.skewY ~= 0 then - love.graphics.shear(transform.skewX, transform.skewY) - end - - love.graphics.translate(-ox, -oy) - love.graphics.translate(transform.translateX, transform.translateY) -end - ---- Remove transform from LÖVE graphics context -function Transform.unapply() - love.graphics.pop() -end - ---- Interpolate between two transforms ----@param from Transform Starting transform ----@param to Transform Ending transform ----@param t number Interpolation factor (0-1) ----@return Transform interpolated -function Transform.lerp(from, to, t) - if type(from) ~= "table" then - from = Transform.new() - end - if type(to) ~= "table" then - to = Transform.new() - end - if type(t) ~= "number" or t ~= t then - t = 0 - elseif t == math.huge then - t = 1 - elseif t == -math.huge then - t = 0 - else - t = math.max(0, math.min(1, t)) - end - - return Transform.new({ - rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, - scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, - scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, - translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, - translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, - skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, - skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, - originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, - originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, - }) -end - ---- Check if transform is identity (no transformation) ----@param transform Transform ----@return boolean isIdentity -function Transform.isIdentity(transform) - if not transform then - return true - end - - return transform.rotate == 0 - and transform.scaleX == 1 - and transform.scaleY == 1 - and transform.translateX == 0 - and transform.translateY == 0 - and transform.skewX == 0 - and transform.skewY == 0 -end - ---- Clone a transform ----@param transform Transform ----@return Transform clone -function Transform.clone(transform) - if not transform then - return Transform.new() - end - - return Transform.new({ - rotate = transform.rotate, - scaleX = transform.scaleX, - scaleY = transform.scaleY, - translateX = transform.translateX, - translateY = transform.translateY, - skewX = transform.skewX, - skewY = transform.skewY, - originX = transform.originX, - originY = transform.originY, - }) -end - --- ============================================================================ --- INTERPOLATION HELPERS --- ============================================================================ - ---- Helper function to interpolate numeric values ----@param startValue number Starting value ----@param finalValue number Final value ----@param easedT number Eased time (0-1) ----@return number interpolated Interpolated value -local function lerpNumber(startValue, finalValue, easedT) - return startValue * (1 - easedT) + finalValue * easedT -end - ---- Helper function to interpolate Color values ----@param startColor any Starting color (Color instance or parseable color) ----@param finalColor any Final color (Color instance or parseable color) ----@param easedT number Eased time (0-1) ----@param ColorModule table Color module reference ----@return any interpolated Interpolated Color instance -local function lerpColor(startColor, finalColor, easedT, ColorModule) - if not ColorModule or not ColorModule.parse or not ColorModule.lerp then - return startColor - end - - local colorA = ColorModule.parse(startColor) - local colorB = ColorModule.parse(finalColor) - - return ColorModule.lerp(colorA, colorB, easedT) -end - ---- Helper function to interpolate table values (padding, margin, cornerRadius) ----@param startTable table Starting table ----@param finalTable table Final table ----@param easedT number Eased time (0-1) ----@return table interpolated Interpolated table -local function lerpTable(startTable, finalTable, easedT) - local result = {} - - local keys = {} - for k in pairs(startTable) do - keys[k] = true - end - for k in pairs(finalTable) do - keys[k] = true - end - - for key in pairs(keys) do - local startVal = startTable[key] - local finalVal = finalTable[key] - - if type(startVal) == "number" and type(finalVal) == "number" then - result[key] = lerpNumber(startVal, finalVal, easedT) - elseif startVal ~= nil then - result[key] = startVal - else - result[key] = finalVal - end - end - - return result -end - ----@class Animation -local Animation = { - _Transform = Transform, -} -Animation.__index = Animation - ---- Build smooth, timed transitions between visual states ----@param props AnimationProps Animation properties ----@return Animation animation The new animation instance -function Animation.new(props) - if type(props) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_001") - end - props = { duration = 1, start = {}, final = {} } - end - - if type(props.duration) ~= "number" or props.duration <= 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_002") - end - props.duration = 1 - end - - if type(props.start) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_001") - end - props.start = {} - end - - if type(props.final) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_001") - end - props.final = {} - end - - local self = setmetatable({}, Animation) - self.duration = props.duration - self.start = props.start - self.final = props.final - self.keyframes = props.keyframes - self.transform = props.transform - self.transition = props.transition - self.elapsed = 0 - - self.onStart = props.onStart - self.onUpdate = props.onUpdate - self.onComplete = props.onComplete - self.onCancel = props.onCancel - self._hasStarted = false - - self._paused = false - self._reversed = false - self._speed = 1.0 - self._state = "pending" - - local easingName = props.easing or "linear" - if type(easingName) == "string" then - self.easing = Easing[easingName] or Easing.linear - elseif type(easingName) == "function" then - self.easing = easingName - else - self.easing = Easing.linear - end - - self._cachedResult = {} - self._resultDirty = true - - return self -end - ---- Advance the animation timeline ----@param dt number Delta time in seconds ----@param element table? Optional element reference for callbacks ----@return boolean completed True if animation is complete -function Animation:update(dt, element) - if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then - dt = 0 - end - - if self._paused then - return false - end - - if self._delay and self._delayElapsed then - if self._delayElapsed < self._delay then - self._delayElapsed = self._delayElapsed + dt - return false - end - end - - if not self._hasStarted then - self._hasStarted = true - self._state = "playing" - if self.onStart and type(self.onStart) == "function" then - local success, err = pcall(self.onStart, self, element) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onStart", - error = tostring(err), - }) - end - end - end - - dt = dt * self._speed - - if self._reversed then - self.elapsed = self.elapsed - dt - if self.elapsed <= 0 then - self.elapsed = 0 - self._state = "completed" - self._resultDirty = true - if self.onComplete and type(self.onComplete) == "function" then - local success, err = pcall(self.onComplete, self, element) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onComplete", - error = tostring(err), - }) - end - end - return true - end - else - self.elapsed = self.elapsed + dt - if self.elapsed >= self.duration then - self.elapsed = self.duration - self._resultDirty = true - - if self._repeatCount then - self._repeatCurrent = (self._repeatCurrent or 0) + 1 - - if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then - if self._yoyo then - self._reversed = not self._reversed - if self._reversed then - self.elapsed = self.duration - else - self.elapsed = 0 - end - else - self.elapsed = 0 - end - return false - end - end - - self._state = "completed" - if self.onComplete and type(self.onComplete) == "function" then - local success, err = pcall(self.onComplete, self, element) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onComplete", - error = tostring(err), - }) - end - end - return true - end - end - - self._resultDirty = true - - if self.onUpdate and type(self.onUpdate) == "function" then - local progress = self.elapsed / self.duration - local success, err = pcall(self.onUpdate, self, element, progress) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onUpdate", - error = tostring(err), - }) - end - end - - return false -end - ---- Find the two keyframes surrounding the current progress ----@param progress number Current animation progress (0-1) ----@return Keyframe? prevFrame The keyframe before current progress ----@return Keyframe? nextFrame The keyframe after current progress -function Animation:findKeyframes(progress) - if not self.keyframes or #self.keyframes < 2 then - return nil, nil - end - - local prevFrame = self.keyframes[1] - local nextFrame = self.keyframes[#self.keyframes] - - for i = 1, #self.keyframes - 1 do - if progress >= self.keyframes[i].at and progress <= self.keyframes[i + 1].at then - prevFrame = self.keyframes[i] - nextFrame = self.keyframes[i + 1] - break - end - end - - return prevFrame, nextFrame -end - ---- Interpolate between two keyframes ----@param prevFrame Keyframe Starting keyframe ----@param nextFrame Keyframe Ending keyframe ----@param easedT number Eased time (0-1) for interpolation ----@return table result Interpolated values -function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) - local result = {} - - local keys = {} - for k in pairs(prevFrame.values) do - keys[k] = true - end - for k in pairs(nextFrame.values) do - keys[k] = true - end - - local numericSet = { - width = true, - height = true, - opacity = true, - x = true, - y = true, - gap = true, - imageOpacity = true, - scrollbarWidth = true, - borderWidth = true, - fontSize = true, - lineHeight = true, - } - - local colorSet = { - backgroundColor = true, - borderColor = true, - textColor = true, - scrollbarColor = true, - scrollbarBackgroundColor = true, - imageTint = true, - } - - local tableSet = { - padding = true, - margin = true, - cornerRadius = true, - } - - for key in pairs(keys) do - local startVal = prevFrame.values[key] - local finalVal = nextFrame.values[key] - - if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then - result[key] = lerpNumber(startVal, finalVal, easedT) - elseif colorSet[key] and Animation._ColorModule then - if startVal ~= nil and finalVal ~= nil then - result[key] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) - end - elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then - result[key] = lerpTable(startVal, finalVal, easedT) - elseif type(startVal) == type(finalVal) then - if type(startVal) == "number" then - result[key] = lerpNumber(startVal, finalVal, easedT) - else - result[key] = finalVal - end - end - end - - return result -end - ---- Calculate the current animated values ----@return table result Interpolated values -function Animation:interpolate() - if not self._resultDirty then - return self._cachedResult - end - - local t = math.min(self.elapsed / self.duration, 1) - - if self.keyframes and type(self.keyframes) == "table" and #self.keyframes >= 2 then - local prevFrame, nextFrame = self:findKeyframes(t) - - if prevFrame and nextFrame then - local localProgress = 0 - if nextFrame.at > prevFrame.at then - localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at) - end - - local easingFn = Easing.linear - if prevFrame.easing then - if type(prevFrame.easing) == "string" then - easingFn = Easing[prevFrame.easing] or Easing.linear - elseif type(prevFrame.easing) == "function" then - easingFn = prevFrame.easing - end - end - - local success, easedT = pcall(easingFn, localProgress) - if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then - easedT = localProgress - end - - local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT) - - local result = self._cachedResult - for k in pairs(result) do - result[k] = nil - end - for k, v in pairs(keyframeResult) do - result[k] = v - end - - self._resultDirty = false - return result - end - end - - local success, easedT = pcall(self.easing, t) - if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then - easedT = t - end - - local result = self._cachedResult - - for k in pairs(result) do - result[k] = nil - end - - local numericProperties = { - "width", - "height", - "opacity", - "x", - "y", - "gap", - "imageOpacity", - "scrollbarWidth", - "borderWidth", - "fontSize", - "lineHeight", - } - - local colorProperties = { - "backgroundColor", - "borderColor", - "textColor", - "scrollbarColor", - "scrollbarBackgroundColor", - "imageTint", - } - - local tableProperties = { - "padding", - "margin", - "cornerRadius", - } - - for _, prop in ipairs(numericProperties) do - local startVal = self.start[prop] - local finalVal = self.final[prop] - - if type(startVal) == "number" and type(finalVal) == "number" then - result[prop] = lerpNumber(startVal, finalVal, easedT) - end - end - - if Animation._ColorModule then - for _, prop in ipairs(colorProperties) do - local startVal = self.start[prop] - local finalVal = self.final[prop] - - if startVal ~= nil and finalVal ~= nil then - result[prop] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) - end - end - end - - for _, prop in ipairs(tableProperties) do - local startVal = self.start[prop] - local finalVal = self.final[prop] - - if type(startVal) == "table" and type(finalVal) == "table" then - result[prop] = lerpTable(startVal, finalVal, easedT) - end - end - - if Animation._Transform and self.start.transform and self.final.transform then - result.transform = Animation._Transform.lerp(self.start.transform, self.final.transform, easedT) - end - - if self.transform and type(self.transform) == "table" then - for key, value in pairs(self.transform) do - result[key] = value - end - end - - self._resultDirty = false - return result -end - ---- Attach animation to an element ----@param element table The element to apply animation to -function Animation:apply(element) - if not element or type(element) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_003") - end - return - end - element.animation = self -end - ---- Apply interpolated values to an element during update. ---- Called each frame while the animation is active (not yet finished). ----@param element table Element to apply interpolated properties to -function Animation:applyInterpolation(element) - local anim = self:interpolate() - - -- Numeric properties - element.width = anim.width or element.width - element.height = anim.height or element.height - element.opacity = anim.opacity or element.opacity - element.x = anim.x or element.x - element.y = anim.y or element.y - element.gap = anim.gap or element.gap - element.imageOpacity = anim.imageOpacity or element.imageOpacity - element.scrollbarWidth = anim.scrollbarWidth or element.scrollbarWidth - element.borderWidth = anim.borderWidth or element.borderWidth - element.fontSize = anim.fontSize or element.fontSize - element.lineHeight = anim.lineHeight or element.lineHeight - - -- Color properties - if anim.backgroundColor then - element.backgroundColor = anim.backgroundColor - end - if anim.borderColor then - element.borderColor = anim.borderColor - end - if anim.textColor then - element.textColor = anim.textColor - end - if anim.scrollbarColor then - element.scrollbarColor = anim.scrollbarColor - end - if anim.scrollbarBackgroundColor then - element.scrollbarBackgroundColor = anim.scrollbarBackgroundColor - end - if anim.imageTint then - element.imageTint = anim.imageTint - end - - -- Table properties - if anim.padding then - element.padding = anim.padding - end - if anim.margin then - element.margin = anim.margin - end - if anim.cornerRadius then - element.cornerRadius = anim.cornerRadius - end - if anim.transform then - element.transform = anim.transform - end - - -- Backward compatibility: opacity-only animation updates background alpha - if anim.opacity and not anim.backgroundColor then - element.backgroundColor.a = anim.opacity - end -end - ---- Pause animation -function Animation:pause() - if self._state == "playing" or self._state == "pending" then - self._paused = true - self._state = "paused" - end -end - ---- Resume animation -function Animation:resume() - if self._state == "paused" then - self._paused = false - self._state = "playing" - end -end - ---- Check if paused ----@return boolean paused -function Animation:isPaused() - return self._paused -end - ---- Reverse animation direction -function Animation:reverse() - self._reversed = not self._reversed -end - ---- Check if reversed ----@return boolean reversed -function Animation:isReversed() - return self._reversed -end - ---- Set playback speed ----@param speed number Speed multiplier -function Animation:setSpeed(speed) - if type(speed) == "number" and speed > 0 then - self._speed = speed - end -end - ---- Get playback speed ----@return number speed -function Animation:getSpeed() - return self._speed -end - ---- Seek to specific time ----@param time number Time in seconds -function Animation:seek(time) - if type(time) == "number" then - self.elapsed = math.max(0, math.min(time, self.duration)) - self._resultDirty = true - end -end - ---- Get animation state ----@return string state -function Animation:getState() - return self._state -end - ---- Cancel animation ----@param element table? Optional element reference -function Animation:cancel(element) - if self._state ~= "cancelled" and self._state ~= "completed" then - self._state = "cancelled" - if self.onCancel and type(self.onCancel) == "function" then - local success, err = pcall(self.onCancel, self, element) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onCancel", - error = tostring(err), - }) - end - end - end -end - ---- Reset animation -function Animation:reset() - self.elapsed = 0 - self._hasStarted = false - self._paused = false - self._state = "pending" - self._resultDirty = true -end - ---- Get animation progress ----@return number progress -function Animation:getProgress() - return math.min(self.elapsed / self.duration, 1) -end - ---- Chain animations ----@param nextAnimation Animation|function ----@return Animation nextAnimation -function Animation:chain(nextAnimation) - if type(nextAnimation) == "function" then - self._nextFactory = nextAnimation - return self - elseif type(nextAnimation) == "table" then - self._next = nextAnimation - return nextAnimation - else - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_004") - end - return self - end -end - ---- Add delay before animation starts ----@param seconds number Delay duration ----@return Animation self -function Animation:delay(seconds) - if type(seconds) ~= "number" or seconds < 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_005") - end - seconds = 0 - end - self._delay = seconds - self._delayElapsed = 0 - return self -end - ---- Set repeat count ----@param count number Repeat count (0 = infinite) ----@return Animation self -function Animation:repeatCount(count) - if type(count) ~= "number" or count < 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_006") - end - count = 0 - end - self._repeatCount = count - self._repeatCurrent = 0 - return self -end - ---- Enable yoyo mode ----@param enabled boolean? Enable yoyo (default: true) ----@return Animation self -function Animation:yoyo(enabled) - if enabled == nil then - enabled = true - end - self._yoyo = enabled - return self -end - ---- Create fade animation ----@param duration number Duration in seconds ----@param fromOpacity number Starting opacity ----@param toOpacity number Ending opacity ----@param easing string? Easing function name ----@return Animation animation -function Animation.fade(duration, fromOpacity, toOpacity, easing) - if type(duration) ~= "number" or duration <= 0 then - duration = 1 - end - if type(fromOpacity) ~= "number" then - fromOpacity = 1 - end - if type(toOpacity) ~= "number" then - toOpacity = 0 - end - - return Animation.new({ - duration = duration, - start = { opacity = fromOpacity }, - final = { opacity = toOpacity }, - easing = easing, - }) -end - ---- Create scale animation ----@param duration number Duration in seconds ----@param fromScale {width:number,height:number} Starting scale ----@param toScale {width:number,height:number} Ending scale ----@param easing string? Easing function name ----@return Animation animation -function Animation.scale(duration, fromScale, toScale, easing) - if type(duration) ~= "number" or duration <= 0 then - duration = 1 - end - if type(fromScale) ~= "table" then - fromScale = { width = 1, height = 1 } - end - if type(toScale) ~= "table" then - toScale = { width = 1, height = 1 } - end - - return Animation.new({ - duration = duration, - start = { width = fromScale.width or 0, height = fromScale.height or 0 }, - final = { width = toScale.width or 0, height = toScale.height or 0 }, - easing = easing, - }) -end - ---- Create keyframe animation ----@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} ----@return Animation animation -function Animation.keyframes(props) - if type(props) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_007") - end - props = { duration = 1, keyframes = {} } - end - - if type(props.duration) ~= "number" or props.duration <= 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_002") - end - props.duration = 1 - end - - if type(props.keyframes) ~= "table" or #props.keyframes < 2 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_008") - end - props.keyframes = { - { at = 0, values = {} }, - { at = 1, values = {} }, - } - end - - local sortedKeyframes = {} - for i, kf in ipairs(props.keyframes) do - if type(kf) == "table" and type(kf.at) == "number" and type(kf.values) == "table" then - table.insert(sortedKeyframes, kf) - end - end - - table.sort(sortedKeyframes, function(a, b) - return a.at < b.at - end) - - if #sortedKeyframes > 0 then - if sortedKeyframes[1].at > 0 then - table.insert(sortedKeyframes, 1, { at = 0, values = sortedKeyframes[1].values }) - end - if sortedKeyframes[#sortedKeyframes].at < 1 then - table.insert(sortedKeyframes, { at = 1, values = sortedKeyframes[#sortedKeyframes].values }) - end - end - - return Animation.new({ - duration = props.duration, - start = {}, - final = {}, - keyframes = sortedKeyframes, - onStart = props.onStart, - onUpdate = props.onUpdate, - onComplete = props.onComplete, - onCancel = props.onCancel, - }) -end - ---- Link an array of animations into a chain (static helper) ---- Each animation's completion triggers the next in sequence ----@param animations Animation[] Array of animations to chain ----@return Animation first The first animation in the chain -function Animation.chainSequence(animations) - if type(animations) ~= "table" or #animations == 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("Animation", "ANIM_004") - end - return Animation.new({ duration = 0, start = {}, final = {} }) - end - - for i = 1, #animations - 1 do - animations[i]:chain(animations[i + 1]) - end - - return animations[1] -end - --- ============================================================================ --- ANIMATION GROUP (Utility) --- ============================================================================ - -local AnimationGroup = {} -AnimationGroup.__index = AnimationGroup - ---- Coordinate multiple animations ----@param props AnimationGroupProps ----@return AnimationGroup group -function AnimationGroup.new(props) - if type(props) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("AnimationGroup", "ANIM_009") - end - props = { animations = {} } - end - - if type(props.animations) ~= "table" or #props.animations == 0 then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("AnimationGroup", "ANIM_010") - end - props.animations = {} - end - - local self = setmetatable({}, AnimationGroup) - - self.animations = props.animations - self.mode = props.mode or "parallel" - self.stagger = props.stagger or 0.1 - self.onComplete = props.onComplete - self.onStart = props.onStart - - if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("AnimationGroup", "ANIM_011", { - mode = tostring(self.mode), - }) - end - self.mode = "parallel" - end - - self._currentIndex = 1 - self._staggerElapsed = 0 - self._startedAnimations = {} - self._hasStarted = false - self._paused = false - self._state = "ready" - - return self -end - ---- Update all animations in parallel ----@param dt number Delta time ----@param element table? Optional element reference ----@return boolean finished -function AnimationGroup:_updateParallel(dt, element) - local allFinished = true - - for i, anim in ipairs(self.animations) do - local isCompleted = false - if type(anim.getState) == "function" then - isCompleted = anim:getState() == "completed" - elseif anim._state then - isCompleted = anim._state == "completed" - end - - if not isCompleted then - local finished = anim:update(dt, element) - if not finished then - allFinished = false - end - end - end - - return allFinished -end - ---- Update animations in sequence ----@param dt number Delta time ----@param element table? Optional element reference ----@return boolean finished -function AnimationGroup:_updateSequence(dt, element) - if self._currentIndex > #self.animations then - return true - end - - local currentAnim = self.animations[self._currentIndex] - local finished = currentAnim:update(dt, element) - - if finished then - self._currentIndex = self._currentIndex + 1 - if self._currentIndex > #self.animations then - return true - end - end - - return false -end - ---- Update animations with stagger ----@param dt number Delta time ----@param element table? Optional element reference ----@return boolean finished -function AnimationGroup:_updateStagger(dt, element) - self._staggerElapsed = self._staggerElapsed + dt - - for i, anim in ipairs(self.animations) do - local startTime = (i - 1) * self.stagger - - if self._staggerElapsed >= startTime and not self._startedAnimations[i] then - self._startedAnimations[i] = true - end - end - - local allFinished = true - for i, anim in ipairs(self.animations) do - if self._startedAnimations[i] then - local isCompleted = false - if type(anim.getState) == "function" then - isCompleted = anim:getState() == "completed" - elseif anim._state then - isCompleted = anim._state == "completed" - end - - if not isCompleted then - local finished = anim:update(dt, element) - if not finished then - allFinished = false - end - end - else - allFinished = false - end - end - - return allFinished -end - ---- Advance all animations in the group ----@param dt number Delta time ----@param element table? Optional element reference ----@return boolean finished -function AnimationGroup:update(dt, element) - if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then - dt = 0 - end - - if self._paused or self._state == "completed" or self._state == "cancelled" then - return self._state == "completed" - end - - if not self._hasStarted then - self._hasStarted = true - self._state = "playing" - if self.onStart and type(self.onStart) == "function" then - local success, err = pcall(self.onStart, self) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onStart", - error = tostring(err), - }) - end - end - end - - local finished = false - - if self.mode == "parallel" then - finished = self:_updateParallel(dt, element) - elseif self.mode == "sequence" then - finished = self:_updateSequence(dt, element) - elseif self.mode == "stagger" then - finished = self:_updateStagger(dt, element) - end - - if finished then - self._state = "completed" - if self.onComplete and type(self.onComplete) == "function" then - local success, err = pcall(self.onComplete, self) - if not success then - Animation._ErrorHandler:warn("Animation", "EVT_002", { - callback = "onComplete", - error = tostring(err), - }) - end - end - end - - return finished -end - ---- Pause all animations -function AnimationGroup:pause() - self._paused = true - for _, anim in ipairs(self.animations) do - if type(anim.pause) == "function" then - anim:pause() - end - end -end - ---- Resume all animations -function AnimationGroup:resume() - self._paused = false - for _, anim in ipairs(self.animations) do - if type(anim.resume) == "function" then - anim:resume() - end - end -end - ---- Check if paused ----@return boolean paused -function AnimationGroup:isPaused() - return self._paused -end - ---- Reverse all animations -function AnimationGroup:reverse() - for _, anim in ipairs(self.animations) do - if type(anim.reverse) == "function" then - anim:reverse() - end - end -end - ---- Set speed for all animations ----@param speed number Speed multiplier -function AnimationGroup:setSpeed(speed) - for _, anim in ipairs(self.animations) do - if type(anim.setSpeed) == "function" then - anim:setSpeed(speed) - end - end -end - ---- Cancel all animations ----@param element table? Optional element reference -function AnimationGroup:cancel(element) - if self._state ~= "cancelled" and self._state ~= "completed" then - self._state = "cancelled" - for _, anim in ipairs(self.animations) do - if type(anim.cancel) == "function" then - anim:cancel(element) - end - end - end -end - ---- Reset all animations -function AnimationGroup:reset() - self._currentIndex = 1 - self._staggerElapsed = 0 - self._startedAnimations = {} - self._hasStarted = false - self._paused = false - self._state = "ready" - - for _, anim in ipairs(self.animations) do - if type(anim.reset) == "function" then - anim:reset() - end - end -end - ---- Get group state ----@return string state -function AnimationGroup:getState() - return self._state -end - ---- Get group progress ----@return number progress -function AnimationGroup:getProgress() - if #self.animations == 0 then - return 1 - end - - if self.mode == "sequence" then - local completedAnims = self._currentIndex - 1 - local currentProgress = 0 - - if self._currentIndex <= #self.animations then - local currentAnim = self.animations[self._currentIndex] - if type(currentAnim.getProgress) == "function" then - currentProgress = currentAnim:getProgress() - end - end - - return (completedAnims + currentProgress) / #self.animations - else - local totalProgress = 0 - for _, anim in ipairs(self.animations) do - if type(anim.getProgress) == "function" then - totalProgress = totalProgress + anim:getProgress() - else - totalProgress = totalProgress + 1 - end - end - return totalProgress / #self.animations - end -end - ---- Apply group to element ----@param element table The element to apply animations to -function AnimationGroup:apply(element) - if not element or type(element) ~= "table" then - if Animation._ErrorHandler then - Animation._ErrorHandler:warn("AnimationGroup", "ANIM_003") - end - return - end - element.animationGroup = self -end - --- ============================================================================ --- MODULE INITIALIZATION --- ============================================================================ - ---- Initialize Animation module with dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler, Color = Color? } -function Animation.init(deps) - if type(deps) == "table" then - Animation._ErrorHandler = deps.ErrorHandler - Animation._ColorModule = deps.Color - end -end - -Animation.Easing = Easing -Animation.Transform = Transform -Animation.Group = AnimationGroup - -return Animation diff --git a/libs/flexlove/modules/Behavior.lua b/libs/flexlove/modules/Behavior.lua deleted file mode 100644 index c4c11b99..00000000 --- a/libs/flexlove/modules/Behavior.lua +++ /dev/null @@ -1,188 +0,0 @@ --- modules/Behavior.lua --- --- Base module for the pluggable behavior system that drives the Behavior & --- Mode Unification refactor. --- --- A *behavior* is a small, stateless table produced by `Behavior.new(spec)` --- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable, --- Scrollable, TextEditable, Selectable, ...) each live in their own module and --- are attached to an Element. The Element's `update`/`draw`/save-restore paths --- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing --- the swarm of `if self.scrollable` / immediate-mode-branch checks previously --- hard-coded in Element.lua. --- --- Element.new iterates a registry of behavior prototypes and auto-attaches --- whichever return true from `shouldAttach(props)`. Element therefore never --- needs to know what an individual behavior does — only that it conforms to --- this interface. --- --- Design constraints (locked — tasks 02-13 depend on this API): --- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler. --- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua). --- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate. --- Do NOT add hooks "just in case"; new capabilities become new behaviors, --- not new hooks. Extending HOOK_NAMES is an architectural decision that must --- be mirrored by every concrete behavior. --- * Immutable instances — behavior tables are produced once and treated as --- read-only. Per-element runtime state lives on the element (or a subsystem --- the behavior attaches), NEVER on the behavior instance itself, so a single --- behavior instance can be shared across many elements. --- --- Lifecycle hook contract (each receives the owning element as first argument): --- onAttach(element) — called once when the behavior is attached --- (element fully constructed). Allocate --- subsystems / register listeners here. --- onDetach(element) — called once when the behavior is detached --- (element destroyed / mode switch). Tear --- down anything onAttach created. --- onUpdate(element, dt) — called every frame from Element:update. --- onDraw(element, ctx) — called every frame from Element:draw; `ctx` --- is the draw context (viewport transform, --- scissor state, theme renderer, ...). --- saveState(element) -> state — called during Element save-state; returns --- a serializable snapshot (or nil) so the --- behavior's runtime state survives the --- immediate-mode recreation cycle. --- restoreState(element, state) — called after reconstruction with the --- snapshot previously returned by saveState. --- --- shouldAttach(props) -> boolean — class-level predicate (not a hook): given --- an element's props table, return true if --- this behavior should be auto-attached. --- Defaults to false (opt-in). - ---- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach ---- predicate. All hooks are always present (custom override or no-op default). ----@class Behavior ----@field onAttach fun(element:table) ----@field onDetach fun(element:table) ----@field onUpdate fun(element:table, dt:number) ----@field onDraw fun(element:table, ctx:table) ----@field saveState fun(element:table):any ----@field restoreState fun(element:table, state:any) ----@field shouldAttach fun(props:table):boolean - -local Behavior = {} - --- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks --- (Element behavior iteration) can rely on a deterministic dispatch sequence. --- HOOK_NAMES is intentionally NOT extended casually — see file header. -Behavior.HOOK_NAMES = { - "onAttach", - "onDetach", - "onUpdate", - "onDraw", - "saveState", - "restoreState", -} - --- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so --- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing. --- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys --- (drawLayer) may hold any value. -local ALLOWED_KEYS = { - onAttach = true, - onDetach = true, - onUpdate = true, - onDraw = true, - saveState = true, - restoreState = true, - shouldAttach = true, - drawLayer = true, -} - --- Spec keys whose values are NOT required to be functions (passive metadata --- consumed by dispatch sites, e.g. Element:draw's pre/post-children split). -local NON_FUNCTION_KEYS = { - drawLayer = true, -} - --- Default no-op hook. Behaviors override only the hooks they need; every other --- hook resolves to this so dispatch sites never have to nil-check. -local function noop() end - --- Default shouldAttach predicate: never auto-attach unless the behavior opts in --- by providing its own predicate. This is the safe default — a behavior with no --- opinion about which elements it applies to stays inert in the auto-attach --- pass (it can still be attached explicitly by name in a later task). -local function defaultShouldAttach() - return false -end - --- Module-level default predicate exposed for callers/tests that want to --- reference the base default directly without constructing an instance. -Behavior.shouldAttach = defaultShouldAttach - ---- Factory: create a frozen behavior instance from a spec table. ---- ---- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names ---- plus `shouldAttach`; each value (when present) must be a function. The ---- returned table contains every lifecycle hook (custom override OR no-op) and ---- a `shouldAttach` predicate (custom OR always-false default), so dispatch ---- sites can call any hook unconditionally without nil-checking. ---- ---- Unknown spec keys and non-function values raise an error immediately so ---- mistakes fail fast at construction rather than as silent no-ops later. ---- ----@param spec table|nil spec table overriding select hooks / shouldAttach ----@return Behavior -function Behavior.new(spec) - spec = spec or {} - - -- Validate spec keys up front so typos surface here, not as silent no-ops. - for key, value in pairs(spec) do - if not ALLOWED_KEYS[key] then - error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2) - end - if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then - error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2) - end - end - - local instance = {} - - -- Populate every lifecycle hook: custom override when provided, no-op default - -- otherwise. Guarantees `instance.hook` is always callable. - for _, hook in ipairs(Behavior.HOOK_NAMES) do - instance[hook] = spec[hook] or noop - end - - -- shouldAttach defaults to always-false; behaviors opt in by supplying one. - instance.shouldAttach = spec.shouldAttach or defaultShouldAttach - - -- drawLayer: optional metadata field (default nil = "background"/pre-children). - -- Dispatch sites (Element:draw) use it to split rendering into pre-children - -- (background layers) and post-children (overlay layers, e.g. scrollbars). - instance.drawLayer = spec.drawLayer - - -- Freeze: prevent adding new fields. Behavior instances are shared, stateless - -- objects; runtime state belongs on the element, never on the behavior. - -- (Reassigning an existing hook is still possible via direct index write — - -- Lua metatables cannot intercept that — but the freeze communicates intent - -- and catches accidental field additions.) - local mt = { - __newindex = function(_, key) - error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2) - end, - --- Mark the metatable so consumers can detect a Behavior instance. - ---@return string - __tostring = function() - return "Behavior" - end, - __metatable = "Behavior", - } - setmetatable(instance, mt) - - return instance -end - ---- Type guard: returns true if `value` is a Behavior instance produced by ---- `Behavior.new`. Used by Element's attach path to validate registry entries ---- without depending on identity. ----@param value any ----@return boolean -function Behavior.isBehavior(value) - return type(value) == "table" and getmetatable(value) == "Behavior" -end - -return Behavior diff --git a/libs/flexlove/modules/Blur.lua b/libs/flexlove/modules/Blur.lua deleted file mode 100644 index 413592a9..00000000 --- a/libs/flexlove/modules/Blur.lua +++ /dev/null @@ -1,686 +0,0 @@ --- Lua 5.2+ compatibility for unpack -local unpack = table.unpack or unpack - --- Warning cache to prevent duplicate warnings for the same element -local warningCache = {} - -local Cache = { - canvases = {}, - quads = {}, - blurInstances = {}, -- Cache blur instances by quality - blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode - MAX_CANVAS_SIZE = 20, - MAX_QUAD_SIZE = 20, - MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases - RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius - LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px) -} - ---- Round canvas size to nearest bucket for better reuse ----@param size number Size to bucket ----@return number bucketSize Bucketed size -local function bucketSize(size) - if size <= 128 then - return math.ceil(size / 32) * 32 - elseif size <= 512 then - return math.ceil(size / 64) * 64 - elseif size <= 1024 then - return math.ceil(size / 128) * 128 - else - return math.ceil(size / 256) * 256 - end -end - ---- Get or create a canvas from cache ----@param width number Canvas width ----@param height number Canvas height ----@return love.Canvas canvas The cached or new canvas -function Cache.getCanvas(width, height) - -- Use bucketed sizes for better cache reuse - local bucketedWidth = bucketSize(width) - local bucketedHeight = bucketSize(height) - local key = string.format("%dx%d", bucketedWidth, bucketedHeight) - - if not Cache.canvases[key] then - Cache.canvases[key] = {} - end - - local cache = Cache.canvases[key] - - for i, entry in ipairs(cache) do - if not entry.inUse then - entry.inUse = true - return entry.canvas - end - end - - local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight) - table.insert(cache, { canvas = canvas, inUse = true }) - - if #cache > Cache.MAX_CANVAS_SIZE then - local removed = table.remove(cache, 1) - if removed and removed.canvas then - removed.canvas:release() - end - end - - return canvas -end - ---- Release a canvas back to the cache ----@param canvas love.Canvas Canvas to release -function Cache.releaseCanvas(canvas) - for _, sizeCache in pairs(Cache.canvases) do - for _, entry in ipairs(sizeCache) do - if entry.canvas == canvas then - entry.inUse = false - return - end - end - end -end - ---- Get or create a quad from cache ----@param x number X position ----@param y number Y position ----@param width number Quad width ----@param height number Quad height ----@param sw number Source width ----@param sh number Source height ----@return love.Quad quad The cached or new quad -function Cache.getQuad(x, y, width, height, sw, sh) - local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh) - - if not Cache.quads[key] then - Cache.quads[key] = {} - end - - local cache = Cache.quads[key] - - for i, entry in ipairs(cache) do - if not entry.inUse then - entry.inUse = true - return entry.quad - end - end - - local quad = love.graphics.newQuad(x, y, width, height, sw, sh) - table.insert(cache, { quad = quad, inUse = true }) - - if #cache > Cache.MAX_QUAD_SIZE then - table.remove(cache, 1) - end - - return quad -end - ---- Release a quad back to the cache ----@param quad love.Quad Quad to release -function Cache.releaseQuad(quad) - for _, keyCache in pairs(Cache.quads) do - for _, entry in ipairs(keyCache) do - if entry.quad == quad then - entry.inUse = false - return - end - end - end -end - ---- Generate cache key for blurred canvas ----@param elementId string Element ID ----@param x number X position ----@param y number Y position ----@param width number Width ----@param height number Height ----@param radius number Blur radius ----@param quality number Blur quality ----@param isBackdrop boolean Whether this is backdrop blur ----@return string key Cache key -function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop) - return string.format( - "%s:%d:%d:%d:%d:%.1f:%d:%s", - elementId, - x, - y, - width, - height, - radius, - quality, - tostring(isBackdrop) - ) -end - ---- Get cached blurred canvas ----@param key string Cache key ----@return love.Canvas|nil canvas Cached canvas or nil -function Cache.getBlurredCanvas(key) - local entry = Cache.blurredCanvases[key] - if entry then - entry.lastUsed = os.time() - return entry.canvas - end - return nil -end - ---- Store blurred canvas in cache ----@param key string Cache key ----@param canvas love.Canvas Canvas to cache -function Cache.setBlurredCanvas(key, canvas) - -- Limit cache size - local count = 0 - for _ in pairs(Cache.blurredCanvases) do - count = count + 1 - end - - if count >= Cache.MAX_BLURRED_CANVAS_CACHE then - -- Remove oldest entry - local oldestKey = nil - local oldestTime = math.huge - for k, v in pairs(Cache.blurredCanvases) do - if v.lastUsed < oldestTime then - oldestTime = v.lastUsed - oldestKey = k - end - end - - if oldestKey then - if Cache.blurredCanvases[oldestKey].canvas then - Cache.blurredCanvases[oldestKey].canvas:release() - end - Cache.blurredCanvases[oldestKey] = nil - end - end - - Cache.blurredCanvases[key] = { - canvas = canvas, - lastUsed = os.time(), - } -end - ---- Clear blurred canvas cache for specific element ----@param elementId string Element ID to clear cache for -function Cache.clearBlurredCanvasesForElement(elementId) - for key, entry in pairs(Cache.blurredCanvases) do - if key:match("^" .. elementId .. ":") then - if entry.canvas then - entry.canvas:release() - end - Cache.blurredCanvases[key] = nil - end - end -end - ---- Clear all caches -function Cache.clear() - -- Release all blurred canvases - for _, entry in pairs(Cache.blurredCanvases) do - if entry.canvas then - entry.canvas:release() - end - end - - Cache.canvases = {} - Cache.quads = {} - Cache.blurInstances = {} - Cache.blurredCanvases = {} - warningCache = {} -- Clear warning cache on cache clear -end - --- ============================================================================ --- SHADER BUILDER --- ============================================================================ - -local ShaderBuilder = {} - ---- Build Gaussian blur shader with given parameters ----@param taps number Number of samples (must be odd, >= 3) ----@param offset number Offset value ----@param offsetType string "weighted" or "center" ----@param sigma number Sigma value for Gaussian distribution ----@return love.Shader shader The compiled blur shader -function ShaderBuilder.build(taps, offset, offsetType, sigma) - taps = math.floor(taps) - sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 - sigma = math.max(sigma, 1) - - local steps = (taps + 1) / 2 - - local gOffsets = {} - local gWeights = {} - for i = 1, steps do - gOffsets[i] = offset * (i - 1) - gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2) - end - - local offsets = {} - local weights = {} - for i = #gWeights, 2, -2 do - local oA, oB = gOffsets[i], gOffsets[i - 1] - local wA, wB = gWeights[i], gWeights[i - 1] - wB = oB == 0 and wB / 2 or wB - local weight = wA + wB - offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight - weights[#weights + 1] = weight - end - - local code = { - [[ - extern vec2 direction; - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]], - } - - local norm = 0 - if #gWeights % 2 == 0 then - code[#code + 1] = "vec4 c = vec4( 0.0 );" - else - local weight = gWeights[1] - norm = norm + weight - code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight) - end - - local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" - for i = 1, #offsets do - local offset = offsets[i] - local weight = weights[i] - norm = norm + weight * 2 - code[#code + 1] = string.format(template, weight, offset, offset) - end - code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm) - - local shaderCode = table.concat(code) - return love.graphics.newShader(shaderCode) -end - ---- Get or create a blur instance from cache ----@param quality number Quality level (1-10) ----@return table blurData Cached blur data {shader, taps} -function Cache.getBlurInstance(quality) - if not Cache.blurInstances[quality] then - local taps = 3 + (quality - 1) * 1.5 - taps = math.floor(taps) - if taps % 2 == 0 then - taps = taps + 1 - end - - local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1) - Cache.blurInstances[quality] = { - shader = shader, - taps = taps, - } - end - - return Cache.blurInstances[quality] -end - ----@class BlurProps ----@field quality number? Quality level (1-10, default: 5) - ----@class Blur ----@field shader love.Shader The blur shader ----@field quality number Quality level (1-10) ----@field taps number Number of shader taps ----@field _ErrorHandler table? Reference to ErrorHandler module -local Blur = {} -Blur.__index = Blur - ---- Check if we should warn about large blur area in immediate mode ----@param elementId string|nil Element ID for caching warnings ----@param width number Blur area width ----@param height number Blur area height ----@param blurType string "content" or "backdrop" -local function checkLargeBlurWarning(elementId, width, height, blurType) - -- Skip if no ErrorHandler available - if not Blur._ErrorHandler then - return - end - - -- Skip if not in immediate mode - if not Blur._blurOptimizations then - return - end - - -- Calculate blur area - local area = width * height - - -- Skip if area is below threshold - if area <= Cache.LARGE_BLUR_THRESHOLD then - return - end - - -- Generate warning key (use elementId if available, otherwise use dimensions) - local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType) - - -- Skip if already warned for this element/area - if warningCache[warningKey] then - return - end - - -- Mark as warned - warningCache[warningKey] = true - - -- Issue warning - local message = - string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area) - - local suggestion = - "Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode." - - Blur._ErrorHandler:warn("Blur", "PERF_003", { - area = string.format("%.0fx%.0f", width or 0, height or 0), - }) -end - ---- Create a new blur effect instance ----@param props BlurProps? Blur configuration ----@return Blur blur The new blur instance -function Blur.new(props) - props = props or {} - - local quality = props.quality or 5 - quality = math.max(1, math.min(10, quality)) - - -- Get cached blur instance for this quality level - local blurData = Cache.getBlurInstance(quality) - - local self = setmetatable({}, Blur) - self.shader = blurData.shader - self.quality = quality - self.taps = blurData.taps - - return self -end - ---- Apply blur to a region of the screen ----@param radius number Blur radius in pixels ----@param x number X position ----@param y number Y position ----@param width number Width of region ----@param height number Height of region ----@param drawFunc function Function to draw content to be blurred -function Blur:applyToRegion(radius, x, y, width, height, drawFunc) - if type(drawFunc) ~= "function" then - if Blur._ErrorHandler then - Blur._ErrorHandler:warn("Blur", "BLUR_001") - end - return - end - - if radius <= 0 or width <= 0 or height <= 0 then - drawFunc() - return - end - - -- Early exit for very low radius (optimization) - if radius < Cache.RADIUS_THRESHOLD then - drawFunc() - return - end - - -- Check for large blur area in immediate mode - checkLargeBlurWarning(nil, width, height, "content") - - -- Calculate offset multiplier based on radius and quality - -- Higher quality = more samples = smaller steps for same radius - local offsetMultiplier = radius / self.quality - - local canvas1 = Cache.getCanvas(width, height) - local canvas2 = Cache.getCanvas(width, height) - - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - love.graphics.push() - love.graphics.origin() - love.graphics.translate(-x, -y) - drawFunc() - love.graphics.pop() - - love.graphics.setShader(self.shader) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.setBlendMode("alpha", "premultiplied") - - -- Single pass with radius-controlled offset - love.graphics.setCanvas(canvas2) - love.graphics.clear() - self.shader:send("direction", { offsetMultiplier / width, 0 }) - love.graphics.draw(canvas1, 0, 0) - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - self.shader:send("direction", { 0, offsetMultiplier / height }) - love.graphics.draw(canvas2, 0, 0) - - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(canvas1, x, y) - - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - - Cache.releaseCanvas(canvas1) - Cache.releaseCanvas(canvas2) -end - ---- Apply backdrop blur effect (blur content behind a region) ----@param radius number Blur radius in pixels ----@param x number X position ----@param y number Y position ----@param width number Width of region ----@param height number Height of region ----@param backdropCanvas love.Canvas Canvas containing the backdrop content -function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas) - if not backdropCanvas then - if Blur._ErrorHandler then - Blur._ErrorHandler:warn("Blur", "BLUR_002") - end - return - end - - if radius <= 0 or width <= 0 or height <= 0 then - return - end - - -- Early exit for very low radius (optimization) - if radius < Cache.RADIUS_THRESHOLD then - return - end - - -- Calculate offset multiplier based on radius and quality - local offsetMultiplier = radius / self.quality - - local canvas1 = Cache.getCanvas(width, height) - local canvas2 = Cache.getCanvas(width, height) - - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.setBlendMode("alpha", "premultiplied") - - local backdropWidth, backdropHeight = backdropCanvas:getDimensions() - local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight) - love.graphics.draw(backdropCanvas, quad, 0, 0) - - love.graphics.setShader(self.shader) - - -- Single pass with radius-controlled offset - love.graphics.setCanvas(canvas2) - love.graphics.clear() - self.shader:send("direction", { offsetMultiplier / width, 0 }) - love.graphics.draw(canvas1, 0, 0) - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - self.shader:send("direction", { 0, offsetMultiplier / height }) - love.graphics.draw(canvas2, 0, 0) - - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(canvas1, x, y) - - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - - Cache.releaseCanvas(canvas1) - Cache.releaseCanvas(canvas2) - Cache.releaseQuad(quad) -end - ---- Get the current quality level ----@return number quality Quality level (1-10) -function Blur:getQuality() - return self.quality -end - ---- Get the number of shader taps ----@return number taps Number of shader taps -function Blur:getTaps() - return self.taps -end - ---- Clear all caches (call on window resize or memory cleanup) -function Blur.clearCache() - Cache.clear() -end - ---- Apply backdrop blur with caching support ----@param radius number Blur radius in pixels ----@param x number X position ----@param y number Y position ----@param width number Width of region ----@param height number Height of region ----@param backdropCanvas love.Canvas Canvas containing the backdrop content ----@param elementId string|nil Element ID for caching (nil disables caching) -function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId) - -- If caching is disabled or no element ID, fall back to regular apply - if not Blur._blurOptimizations or not elementId then - return self:applyBackdrop(radius, x, y, width, height, backdropCanvas) - end - - -- Generate cache key - local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true) - - -- Check cache - local cachedCanvas = Cache.getBlurredCanvas(cacheKey) - if cachedCanvas then - -- Draw cached blur - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(cachedCanvas, x, y) - - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - return - end - - -- Not cached, render and cache - if not backdropCanvas then - if Blur._ErrorHandler then - Blur._ErrorHandler:warn("Blur", "BLUR_002") - end - return - end - - if radius <= 0 or width <= 0 or height <= 0 then - return - end - - -- Early exit for very low radius (optimization) - if radius < Cache.RADIUS_THRESHOLD then - return - end - - -- Check for large blur area in immediate mode - checkLargeBlurWarning(elementId, width, height, "backdrop") - - -- Calculate offset multiplier based on radius and quality - local offsetMultiplier = radius / self.quality - - local canvas1 = Cache.getCanvas(width, height) - local canvas2 = Cache.getCanvas(width, height) - - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.setBlendMode("alpha", "premultiplied") - - local backdropWidth, backdropHeight = backdropCanvas:getDimensions() - local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight) - love.graphics.draw(backdropCanvas, quad, 0, 0) - - love.graphics.setShader(self.shader) - - -- Single pass with radius-controlled offset - love.graphics.setCanvas(canvas2) - love.graphics.clear() - self.shader:send("direction", { offsetMultiplier / width, 0 }) - love.graphics.draw(canvas1, 0, 0) - - love.graphics.setCanvas(canvas1) - love.graphics.clear() - self.shader:send("direction", { 0, offsetMultiplier / height }) - love.graphics.draw(canvas2, 0, 0) - - -- Cache the result - local cachedResult = love.graphics.newCanvas(width, height) - love.graphics.setCanvas(cachedResult) - love.graphics.clear() - love.graphics.setShader() - love.graphics.setBlendMode("alpha", "premultiplied") - love.graphics.draw(canvas1, 0, 0) - Cache.setBlurredCanvas(cacheKey, cachedResult) - - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(canvas1, x, y) - - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - - Cache.releaseCanvas(canvas1) - Cache.releaseCanvas(canvas2) - Cache.releaseQuad(quad) -end - ---- Clear blur cache for specific element ----@param elementId string Element ID -function Blur.clearElementCache(elementId) - Cache.clearBlurredCanvasesForElement(elementId) -end - ---- Initialize Blur module with dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? } -function Blur.init(deps) - if type(deps) == "table" then - Blur._ErrorHandler = deps.ErrorHandler - Blur._blurOptimizations = deps.immediateModeOptimizations or false - end -end - -Blur.Cache = Cache -Blur.ShaderBuilder = ShaderBuilder - -return Blur diff --git a/libs/flexlove/modules/Calc.lua b/libs/flexlove/modules/Calc.lua deleted file mode 100644 index b62d6bb6..00000000 --- a/libs/flexlove/modules/Calc.lua +++ /dev/null @@ -1,385 +0,0 @@ ---- Utility module for parsing and evaluating CSS-like calc() expressions ---- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh) ----@class Calc -local Calc = {} - ---- Initialize Calc module with dependencies ----@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? } -function Calc.init(deps) - Calc._ErrorHandler = deps.ErrorHandler -end - ---- Token types for lexical analysis -local TokenType = { - NUMBER = "NUMBER", - UNIT = "UNIT", - PLUS = "PLUS", - MINUS = "MINUS", - MULTIPLY = "MULTIPLY", - DIVIDE = "DIVIDE", - LPAREN = "LPAREN", - RPAREN = "RPAREN", - EOF = "EOF", -} - ---- Tokenize a calc expression string into tokens ----@param expr string The expression to tokenize (e.g., "50% - 10vw") ----@return CalcToken[]? tokens Array of tokens with type, value, unit ----@return string? error Error message if tokenization fails -local function tokenize(expr) - local tokens = {} - local i = 1 - local len = #expr - - while i <= len do - local char = expr:sub(i, i) - - -- Skip whitespace - if char:match("%s") then - i = i + 1 - -- Number (including decimals, but NOT negative - handled separately below) - elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then - local numStr = "" - - -- Parse integer and decimal parts - while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do - numStr = numStr .. expr:sub(i, i) - i = i + 1 - end - - local num = tonumber(numStr) - if not num then - return nil, "Invalid number: " .. numStr - end - - -- Check for unit following the number - local unitStr = "" - while i <= len and expr:sub(i, i):match("[%a%%]") do - unitStr = unitStr .. expr:sub(i, i) - i = i + 1 - end - - -- Default to px if no unit - if unitStr == "" then - unitStr = "px" - end - - -- Validate unit - local validUnits = { px = true, ["%"] = true, vw = true, vh = true } - if not validUnits[unitStr] then - return nil, "Invalid unit: " .. unitStr - end - - table.insert(tokens, { - type = TokenType.NUMBER, - value = num, - unit = unitStr, - }) - -- Operators - elseif char == "+" then - table.insert(tokens, { type = TokenType.PLUS }) - i = i + 1 - elseif char == "-" then - -- Check if this is a negative number or subtraction - -- It's a negative number if previous token is an operator or opening paren - local prevToken = tokens[#tokens] - if - not prevToken - or prevToken.type == TokenType.PLUS - or prevToken.type == TokenType.MINUS - or prevToken.type == TokenType.MULTIPLY - or prevToken.type == TokenType.DIVIDE - or prevToken.type == TokenType.LPAREN - then - -- This is a negative number, continue to number parsing - local numStr = "-" - i = i + 1 - - -- Parse integer and decimal parts - while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do - numStr = numStr .. expr:sub(i, i) - i = i + 1 - end - - local num = tonumber(numStr) - if not num then - return nil, "Invalid number: " .. numStr - end - - -- Check for unit following the number - local unitStr = "" - while i <= len and expr:sub(i, i):match("[%a%%]") do - unitStr = unitStr .. expr:sub(i, i) - i = i + 1 - end - - -- Default to px if no unit - if unitStr == "" then - unitStr = "px" - end - - -- Validate unit - local validUnits = { px = true, ["%"] = true, vw = true, vh = true } - if not validUnits[unitStr] then - return nil, "Invalid unit: " .. unitStr - end - - table.insert(tokens, { - type = TokenType.NUMBER, - value = num, - unit = unitStr, - }) - else - -- This is subtraction operator - table.insert(tokens, { type = TokenType.MINUS }) - i = i + 1 - end - elseif char == "*" then - table.insert(tokens, { type = TokenType.MULTIPLY }) - i = i + 1 - elseif char == "/" then - table.insert(tokens, { type = TokenType.DIVIDE }) - i = i + 1 - elseif char == "(" then - table.insert(tokens, { type = TokenType.LPAREN }) - i = i + 1 - elseif char == ")" then - table.insert(tokens, { type = TokenType.RPAREN }) - i = i + 1 - else - return nil, "Unexpected character: " .. char - end - end - - table.insert(tokens, { type = TokenType.EOF }) - return tokens -end - ---- Parser for calc expressions using recursive descent ----@class Parser ----@field tokens CalcToken[] Array of tokens ----@field pos number Current token position -local Parser = {} -Parser.__index = Parser - ---- Create a new parser ----@param tokens CalcToken[] Array of tokens ----@return Parser -function Parser.new(tokens) - local self = setmetatable({}, Parser) - self.tokens = tokens - self.pos = 1 - return self -end - ---- Get current token ----@return CalcToken token Current token -function Parser:current() - return self.tokens[self.pos] -end - ---- Advance to next token -function Parser:advance() - self.pos = self.pos + 1 -end - ---- Parse expression (handles + and -) ----@return CalcASTNode ast Abstract syntax tree node -function Parser:parseExpression() - local left = self:parseTerm() - - while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do - local op = self:current().type - self:advance() - local right = self:parseTerm() - left = { - type = op == TokenType.PLUS and "add" or "subtract", - left = left, - right = right, - } - end - - return left -end - ---- Parse term (handles * and /) ----@return CalcASTNode ast Abstract syntax tree node -function Parser:parseTerm() - local left = self:parseFactor() - - while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do - local op = self:current().type - self:advance() - local right = self:parseFactor() - left = { - type = op == TokenType.MULTIPLY and "multiply" or "divide", - left = left, - right = right, - } - end - - return left -end - ---- Parse factor (handles numbers and parentheses) ----@return CalcASTNode ast Abstract syntax tree node -function Parser:parseFactor() - local token = self:current() - - if token.type == TokenType.NUMBER then - self:advance() - return { - type = "number", - value = token.value, - unit = token.unit, - } - elseif token.type == TokenType.LPAREN then - self:advance() - local expr = self:parseExpression() - if self:current().type ~= TokenType.RPAREN then - error("Expected closing parenthesis") - end - self:advance() - return expr - else - error("Unexpected token: " .. token.type) - end -end - ---- Parse the tokens into an AST ----@return CalcASTNode ast Abstract syntax tree -function Parser:parse() - local ast = self:parseExpression() - if self:current().type ~= TokenType.EOF then - error("Unexpected tokens after expression") - end - return ast -end - ---- Create a calc expression object that can be resolved later ---- This is the main API function that users call ----@param expr string The calc expression (e.g., "50% - 10vw") ----@return CalcObject calcObject A calc expression object with AST -function Calc.new(expr) - -- Tokenize - local tokens, err = tokenize(expr) - if not tokens then - if Calc._ErrorHandler then - Calc._ErrorHandler:warn("Calc", "VAL_006", { - expression = expr, - error = err, - }) - end - -- Return a fallback calc object that resolves to 0 - return { - _isCalc = true, - _expr = expr, - _ast = nil, - _error = err, - } - end - - -- Parse - local parser = Parser.new(tokens) - local success, ast = pcall(function() - return parser:parse() - end) - - if not success then - if Calc._ErrorHandler then - Calc._ErrorHandler:warn("Calc", "VAL_006", { - expression = expr, - error = ast, -- ast contains error message on failure - }) - end - -- Return a fallback calc object that resolves to 0 - return { - _isCalc = true, - _expr = expr, - _ast = nil, - _error = ast, - } - end - - return { - _isCalc = true, - _expr = expr, - _ast = ast, - } -end - ---- Check if a value is a calc expression ----@param value any The value to check ----@return boolean isCalc True if value is a calc expression -function Calc.isCalc(value) - return type(value) == "table" and value._isCalc == true -end - ---- Resolve a calc expression to pixel value ----@param calcObj CalcObject The calc expression object ----@param viewportWidth number Viewport width in pixels ----@param viewportHeight number Viewport height in pixels ----@param parentSize number? Parent dimension for percentage units ----@return number resolvedValue Resolved pixel value -function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize) - if not calcObj._ast then - -- Error during parsing, return 0 - return 0 - end - - --- Evaluate AST node recursively - ---@param node table AST node - ---@return number value Evaluated value in pixels - local function evaluate(node) - if node.type == "number" then - -- Convert unit to pixels - local value = node.value - local unit = node.unit - - if unit == "px" then - return value - elseif unit == "%" then - if not parentSize then - if Calc._ErrorHandler then - Calc._ErrorHandler:warn("Calc", "LAY_003", { - unit = "%", - issue = "parent dimension not available", - }) - end - return 0 - end - return (value / 100) * parentSize - elseif unit == "vw" then - return (value / 100) * viewportWidth - elseif unit == "vh" then - return (value / 100) * viewportHeight - else - return 0 - end - elseif node.type == "add" then - return evaluate(node.left) + evaluate(node.right) - elseif node.type == "subtract" then - return evaluate(node.left) - evaluate(node.right) - elseif node.type == "multiply" then - return evaluate(node.left) * evaluate(node.right) - elseif node.type == "divide" then - local divisor = evaluate(node.right) - if divisor == 0 then - if Calc._ErrorHandler then - Calc._ErrorHandler:warn("Calc", "VAL_006", { - expression = calcObj._expr, - error = "Division by zero", - }) - end - return 0 - end - return evaluate(node.left) / divisor - else - return 0 - end - end - - return evaluate(calcObj._ast) -end - -return Calc diff --git a/libs/flexlove/modules/Color.lua b/libs/flexlove/modules/Color.lua deleted file mode 100644 index 1ea41d37..00000000 --- a/libs/flexlove/modules/Color.lua +++ /dev/null @@ -1,346 +0,0 @@ ----@class Color -local Color = {} -Color.__index = Color - ---- Initialize module with shared dependencies ----@param deps table Dependencies {ErrorHandler} -function Color.init(deps) - if type(deps) == "table" then - Color._ErrorHandler = deps.ErrorHandler - end -end - ---- Build type-safe color objects with automatic validation and clamping ---- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range) ----@param r number? Red component (0-1), defaults to 0 ----@param g number? Green component (0-1), defaults to 0 ----@param b number? Blue component (0-1), defaults to 0 ----@param a number? Alpha component (0-1), defaults to 1 ----@return Color color The new color instance -function Color.new(r, g, b, a) - -- Sanitize and clamp color components - local _, sanitizedR = Color.validateColorChannel(r or 0, 1) - local _, sanitizedG = Color.validateColorChannel(g or 0, 1) - local _, sanitizedB = Color.validateColorChannel(b or 0, 1) - local _, sanitizedA = Color.validateColorChannel(a or 1, 1) - - -- FFI structs don't support metatables/methods without wrapping - -- The wrapping overhead negates the FFI benefits - local self = setmetatable({}, Color) - self.r = sanitizedR or 0 - self.g = sanitizedG or 0 - self.b = sanitizedB or 0 - self.a = sanitizedA or 1 - return self -end - ---- Extract individual color channels for use with love.graphics.setColor() ---- Use this to pass colors to LÖVE's rendering functions ----@return number r Red component (0-1) ----@return number g Green component (0-1) ----@return number b Blue component (0-1) ----@return number a Alpha component (0-1) -function Color:toRGBA() - return self.r, self.g, self.b, self.a -end - ---- Parse CSS-style hex colors into Color objects for designer-friendly workflows ---- Use this to work with colors from design tools that export hex values ----@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA") ----@return Color color The parsed color (returns white on error with warning) -function Color.fromHex(hexWithTag) - -- Validate input type - if type(hexWithTag) ~= "string" then - Color._ErrorHandler:warn("Color", "VAL_004", { - input = tostring(hexWithTag), - issue = "not a string", - fallback = "white (#FFFFFF)", - }) - return Color.new(1, 1, 1, 1) - end - - local hex = hexWithTag:gsub("#", "") - if #hex == 6 then - local r = tonumber("0x" .. hex:sub(1, 2)) - local g = tonumber("0x" .. hex:sub(3, 4)) - local b = tonumber("0x" .. hex:sub(5, 6)) - if not r or not g or not b then - Color._ErrorHandler:warn("Color", "VAL_004", { - input = hexWithTag, - issue = "invalid hex digits", - fallback = "white (#FFFFFF)", - }) - return Color.new(1, 1, 1, 1) -- Return white as fallback - end - return Color.new(r / 255, g / 255, b / 255, 1) - elseif #hex == 8 then - local r = tonumber("0x" .. hex:sub(1, 2)) - local g = tonumber("0x" .. hex:sub(3, 4)) - local b = tonumber("0x" .. hex:sub(5, 6)) - local a = tonumber("0x" .. hex:sub(7, 8)) - if not r or not g or not b or not a then - Color._ErrorHandler:warn("Color", "VAL_004", { - input = hexWithTag, - issue = "invalid hex digits", - fallback = "white (#FFFFFFFF)", - }) - return Color.new(1, 1, 1, 1) -- Return white as fallback - end - return Color.new(r / 255, g / 255, b / 255, a / 255) - else - Color._ErrorHandler:warn("Color", "VAL_004", { - input = hexWithTag, - expected = "#RRGGBB or #RRGGBBAA", - hexLength = #hex, - fallback = "white (#FFFFFF)", - }) - return Color.new(1, 1, 1, 1) -- Return white as fallback - end -end - ---- Verify and sanitize individual color components to prevent rendering errors ---- Use this to safely process user input or external color data ----@param value any Value to validate ----@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1 ----@return boolean valid True if valid ----@return number? clamped Clamped value in 0-1 range, nil if invalid -function Color.validateColorChannel(value, max) - max = max or 1 - - if type(value) ~= "number" then - return false, nil - end - - -- Check for NaN - if value ~= value then - return false, nil - end - - -- Check for Infinity - if value == math.huge or value == -math.huge then - return false, nil - end - - -- Normalize to 0-1 range - local normalized = value - if max == 255 then - normalized = value / 255 - end - - -- Clamp to valid range - normalized = math.max(0, math.min(1, normalized)) - - return true, normalized -end - ---- Validate hex color format ----@param hex string Hex color string (with or without #) ----@return boolean valid True if valid format ----@return string? error Error message if invalid, nil if valid -function Color.validateHexColor(hex) - if type(hex) ~= "string" then - return false, "Hex color must be a string" - end - - -- Remove # prefix - local cleanHex = hex:gsub("^#", "") - - -- Check length (3, 6, or 8 characters) - if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then - return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex) - end - - -- Check for valid hex characters - if not cleanHex:match("^[0-9A-Fa-f]+$") then - return false, "Invalid hex characters. Use only 0-9, A-F" - end - - return true, nil -end - ---- Validate RGB/RGBA color values ----@param r number Red component ----@param g number Green component ----@param b number Blue component ----@param a number? Alpha component (optional, defaults to max) ----@param max number? Maximum value (255 or 1), defaults to 1 ----@return boolean valid True if valid ----@return string? error Error message if invalid, nil if valid -function Color.validateRGBColor(r, g, b, a, max) - max = max or 1 - a = a or max - - local rValid = Color.validateColorChannel(r, max) - local gValid = Color.validateColorChannel(g, max) - local bValid = Color.validateColorChannel(b, max) - local aValid = Color.validateColorChannel(a, max) - - if not rValid then - return false, string.format("Invalid red channel: %s", tostring(r)) - end - if not gValid then - return false, string.format("Invalid green channel: %s", tostring(g)) - end - if not bValid then - return false, string.format("Invalid blue channel: %s", tostring(b)) - end - if not aValid then - return false, string.format("Invalid alpha channel: %s", tostring(a)) - end - - return true, nil -end - ---- Check if a value is a valid color format ----@param value any Value to check ----@return string? format Format type ("hex", "named", "table"), nil if invalid -function Color.isValidColorFormat(value) - local valueType = type(value) - - -- Check for hex string - if valueType == "string" then - if value:match("^#?[0-9A-Fa-f]+$") then - local valid = Color.validateHexColor(value) - if valid then - return "hex" - end - end - - return nil - end - - -- Check for table format - if valueType == "table" then - -- Check for Color instance - if getmetatable(value) == Color then - return "table" - end - - -- Check for array format {r, g, b, a} - if value[1] and value[2] and value[3] then - local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4]) - if valid then - return "table" - end - end - - -- Check for named format {r=, g=, b=, a=} - if value.r and value.g and value.b then - local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a) - if valid then - return "table" - end - end - - return nil - end - - return nil -end - ---- Convert any color format to a valid Color object with graceful fallbacks ---- Use this to robustly handle colors from any source without crashes ----@param value any Color value to sanitize (hex, named, table, or Color instance) ----@param default Color? Default color if invalid (defaults to black) ----@return Color color Sanitized color instance (guaranteed non-nil) -function Color.sanitizeColor(value, default) - default = default or Color.new(0, 0, 0, 1) - - local format = Color.isValidColorFormat(value) - - if not format then - return default - end - - -- Handle hex format - if format == "hex" then - local cleanHex = value:gsub("^#", "") - - -- Expand 3-digit hex to 6-digit - if #cleanHex == 3 then - cleanHex = cleanHex:gsub("(.)", "%1%1") - end - - -- Try to parse - local success, result = pcall(Color.fromHex, "#" .. cleanHex) - if success then - return result - else - return default - end - end - - if format == "table" then - -- Color instance - if getmetatable(value) == Color then - return value - end - - -- Array format - if value[1] then - local _, r = Color.validateColorChannel(value[1], 1) - local _, g = Color.validateColorChannel(value[2], 1) - local _, b = Color.validateColorChannel(value[3], 1) - local _, a = Color.validateColorChannel(value[4] or 1, 1) - - if r and g and b and a then - return Color.new(r, g, b, a) - end - end - - -- Named format - if value.r then - local _, r = Color.validateColorChannel(value.r, 1) - local _, g = Color.validateColorChannel(value.g, 1) - local _, b = Color.validateColorChannel(value.b, 1) - local _, a = Color.validateColorChannel(value.a or 1, 1) - - if r and g and b and a then - return Color.new(r, g, b, a) - end - end - end - - return default -end - ---- Universally convert any color format (hex, named, table) into a Color object ---- Use this as your main color input handler to accept flexible color specifications ----@param value any Color value (hex string, named color, table, or Color instance) ----@return Color color Parsed color instance (defaults to black on error) -function Color.parse(value) - return Color.sanitizeColor(value, Color.new(0, 0, 0, 1)) -end - ---- Smoothly transition between two colors for animations and gradients ---- Use this to create color-based animations without manual channel calculations ----@param colorA Color Starting color ----@param colorB Color Ending color ----@param t number Interpolation factor (0-1) ----@return Color color Interpolated color -function Color.lerp(colorA, colorB, t) - -- Sanitize inputs - if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then - colorA = Color.new(0, 0, 0, 1) - end - if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then - colorB = Color.new(0, 0, 0, 1) - end - if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then - t = 0 - end - - -- Clamp t to 0-1 range - t = math.max(0, math.min(1, t)) - - -- Linear interpolation for each channel - local oneMinusT = 1 - t - local r = colorA.r * oneMinusT + colorB.r * t - local g = colorA.g * oneMinusT + colorB.g * t - local b = colorA.b * oneMinusT + colorB.b * t - local a = colorA.a * oneMinusT + colorB.a * t - - return Color.new(r, g, b, a) -end - -return Color diff --git a/libs/flexlove/modules/Context.lua b/libs/flexlove/modules/Context.lua deleted file mode 100644 index 10f53015..00000000 --- a/libs/flexlove/modules/Context.lua +++ /dev/null @@ -1,596 +0,0 @@ ----@class Context -local modulePath = (...):match("(.-)[^%.]+$") -local ZIndex = require(modulePath .. "ZIndex") -local Element = require(modulePath .. "Element") -local Context = { - topElements = {}, - -- Base scale configuration - baseScale = nil, -- {width: number, height: number} - -- Current scale factors - scaleFactors = { x = 1.0, y = 1.0 }, - defaultTheme = nil, - _focusedElement = nil, - _focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames - _activeEventElement = nil, - _cachedViewport = { width = 0, height = 0 }, - -- Immediate mode state - _immediateMode = false, - _frameNumber = 0, - _currentFrameElements = {}, - _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) - -- Focus management guard - _settingFocus = false, - -- Hook called whenever focus changes: function(element) or nil - _onFocusChanged = nil, - - -- Navigation state - _navigationContext = { - lastFocusedElement = nil, -- For returning from modals - navigationMode = "sequential", -- "sequential" or "directional" - containerElement = nil, -- Current navigation container - }, - - initialized = false, - - -- Expose internal hit-testing helpers for unit testing only. - -- These are populated below after their local definitions. They are NOT part - -- of the public API and must not be relied on by callers; they exist so the - -- shared hit-test core (the single place display:none guarding lives) can be - -- exercised directly by the test suite. Subsequent unified-event-routing - -- tasks consume these locals through the mode-agnostic query functions. - _test = { - pointHitsElement = nil, - elementHasScrollableOverflow = nil, - }, - - -- Debug draw overlay - _debugDraw = false, - _debugDrawKey = nil, - - -- Initialization state tracking - ---@type "uninitialized"|"initializing"|"ready" - _initState = "uninitialized", - ---@type table[] Queue of {props: ElementProps, callback: function(element)|nil} - _initQueue = {}, - - -- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's - -- per-element call (unified-event-routing task 05) doesn't re-walk the tree - -- + realloc + sort for every interactive element sharing the same cursor. - -- Invalidated explicitly by Context.clearInteractiveCache() at the start of - -- each flexlove.update (both modes) and in clearFrameElements (immediate - -- mid-frame rebuild). It also self-invalidates when the topElements table - -- reference changes (tests replace it per-case; immediate-mode beginFrame - -- reassigns it each frame), so direct callers that never go through - -- flexlove.update still see fresh results across tree swaps. - _interactiveLookupCache = { - valid = false, - x = nil, - y = nil, - result = nil, - topElementsRef = nil, - frameNumber = -1, - }, -} - ---- Check if a point hits an element, accounting for scroll offsets and display:none. ---- All mode-agnostic query functions use this as their single hit-test entry point, ---- ensuring fixes like display:none guarding apply everywhere. ---- ---- This is the single canonical place where `element.display == false` short- ---- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is ---- the caller's responsibility: callers walk the parent chain (using ---- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the ---- accumulated scroll offset in here. Keeping the parent walk outside this core ---- lets retained-mode (recursive tree descent) and immediate-mode (flat ---- z-index list) callers share the exact same primitive bounds/display logic. ----@param element Element ----@param mx number Screen X coordinate ----@param my number Screen Y coordinate ----@param scrollOffsetX number? Accumulated scroll offset from parent chain ----@param scrollOffsetY number? Accumulated scroll offset from parent chain ----@return boolean hits -local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY) - scrollOffsetX = scrollOffsetX or 0 - scrollOffsetY = scrollOffsetY or 0 - - -- Skip display:none elements entirely - if element.display == false then - return false - end - - 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) - - local adjustedX = mx + scrollOffsetX - local adjustedY = my + scrollOffsetY - - return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh -end - ---- Check if an element has scrollable/clipped overflow (for scroll offset accumulation). ---- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the ---- overflow values that clip/translate descendant content and therefore require ---- scroll-offset compensation when hit testing descendants. ----@param element Element ----@return boolean -local function elementHasScrollableOverflow(element) - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - return overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - or overflowX == "hidden" - or overflowY == "hidden" -end - --- Expose the two core helpers for unit testing only (see Context._test above). -Context._test.pointHitsElement = pointHitsElement -Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow - --- Public exposure of the canonical hit-test primitive so other modules --- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition` --- tree walks) can share the single implementation of bounds + display:none --- guarding instead of duplicating the `display == false` check inline. --- This keeps "display == false" in exactly one place for hit-testing. -Context.pointHitsElement = pointHitsElement -Context.elementHasScrollableOverflow = elementHasScrollableOverflow - ---- Find the first scrollable element at a screen position, regardless of mode. ---- This is the mode-agnostic successor to the two duplicated scrollable lookups ---- that previously lived inline in `flexlove.wheelmoved`: ---- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and ---- re-implemented bounds + parent-chain clipping + scroll-offset math; and ---- * retained mode — recursed through `Context.topElements` with a private ---- `findScrollableAtPosition(elements, x, y)` helper. ---- Both paths now collapse into this single function, which routes every ---- hit test through `pointHitsElement` (the single place `display == false` ---- is guarded) and every scroll-offset decision through ---- `elementHasScrollableOverflow`. As a result display:none elements are never ---- returned in either mode, fixing the latent bug where the immediate-mode ---- path's `isPointInElement` did not skip display:none elements. ---- ---- The retained-mode branch intentionally mirrors the original ---- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins, ---- children checked before self) but is upgraded to thread accumulated scroll ---- offsets through `pointHitsElement` so nested scrolled containers are tested ---- against their visible position. The original helper is removed once ---- `flexlove.wheelmoved` is rerouted onto this function in task 04. ----@param x number Screen X coordinate ----@param y number Screen Y coordinate ----@return Element|nil The scrollable element, or nil -function Context.findScrollableAtPosition(x, y) - if Context.isImmediateMode() then - -- Immediate mode: iterate the z-index ordered list (reverse order = - -- topmost first). pointHitsElement supplies the bounds + display guard. - for i = #Context._zIndexOrderedElements, 1, -1 do - local element = Context._zIndexOrderedElements[i] - if pointHitsElement(element, x, y) 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 - return element - end - end - end - return nil - else - -- Retained mode: recursive tree walk from topElements. Children are - -- checked before self (deepest scrollable wins); accumulated scroll - -- offsets are threaded through pointHitsElement so descendants of - -- scrolled containers are hit-tested against their translated position. - local function findInTree(elements, scrollOffsetX, scrollOffsetY) - scrollOffsetX = scrollOffsetX or 0 - scrollOffsetY = scrollOffsetY or 0 - for i = #elements, 1, -1 do - local element = elements[i] - if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then - if #element.children > 0 then - local childScrollOffsetX = scrollOffsetX - local childScrollOffsetY = scrollOffsetY - if elementHasScrollableOverflow(element) then - childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) - childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) - end - local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY) - if childResult then - return childResult - end - end - -- No descendant was scrollable — check self. - 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 - return element - end - end - end - return nil - end - return findInTree(Context.topElements) - end -end - ---- Check whether immediate mode is active. ---- This is the single canonical accessor for the mode flag consumed throughout ---- the framework. Mode-aware branches elsewhere call this instead of reading ---- `Context._immediateMode` directly, so the literal mode flag only appears ---- here (its definition) and in StateManager (its mirrored storage) — never ---- scattered across Element / behaviors / managers (behavior-mode-unification ---- task 11). ----@return boolean -function Context.isImmediateMode() - return Context._immediateMode -end - ----@return number, number -- scaleX, scaleY -function Context.getScaleFactors() - return Context.scaleFactors.x, Context.scaleFactors.y -end - ---- Register an element in the z-index ordered tree (for immediate mode) ----@param element Element The element to register -function Context.registerElement(element) - if not Context.isImmediateMode() then - return - end - - table.insert(Context._zIndexOrderedElements, element) -end - -function Context.clearFrameElements() - Context._zIndexOrderedElements = {} - Context.clearInteractiveCache() -end - ---- Compute the composite z-index key for an element. ---- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ ---- ---- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance. ---- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above ---- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the ---- remaining 3 digits without interfering with the depth component. ---- ---- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which ---- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15). ---- ---- This is the SINGLE canonical z-index ordering function, used by both ---- sortElementsByZIndex (the immediate-mode flat list sort) and ---- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them ---- on the same key ensures the interactive topmost element matches the visual ---- draw order — a button in a z=50 MainMenu window must occlude a button in a ---- z=0 BottomBar even when both buttons default to own z=0. -local function getEffectiveZIndex(elem) - local ownZ = elem.z or 0 - local rootZ = ownZ - local depth = 0 - local current = elem.parent - while current do - rootZ = current.z or 0 - depth = depth + 1 - current = current.parent - end - return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ -end - --- Public exposure so FlexLove.getElementAtPosition shares the single --- implementation instead of duplicating the parent-chain walk as a closure. -Context.getEffectiveZIndex = getEffectiveZIndex - ---- Sort elements by z-index (called after all elements are registered) -function Context.sortElementsByZIndex() - -- Precompute the composite key ONCE per element so the sort comparator is a - -- pure table lookup (O(1)) instead of re-walking the parent chain on every - -- O(N log N) comparison. This function runs every frame in immediate mode. - local elements = Context._zIndexOrderedElements - local zIndices = {} - for i = 1, #elements do - zIndices[elements[i]] = getEffectiveZIndex(elements[i]) - end - table.sort(elements, function(a, b) - return zIndices[a] < zIndices[b] - end) -end - ---- Find the topmost interactive element at a screen position, regardless of mode. ---- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed ---- in unified-event-routing task 05) and the retained-mode `_activeEventElement` ---- mechanism — both are now funneled through this single entry point. ---- ---- In immediate mode this replaces Context.getTopElementAt() (which only worked ---- in immediate mode). In retained mode this provides the same role as the ---- _activeEventElement set by flexlove.getElementAtPosition(). ---- ---- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable. ----@param x number Screen X coordinate ----@param y number Screen Y coordinate ----@return Element|nil The topmost interactive element, or nil -function Context.findInteractiveAtPosition(x, y) - -- Per-frame cache: Clickable.onUpdate runs this for every interactive - -- element under the same cursor, but the result for a given (x,y) is - -- identical across all of them within a single update pass. Returning a - -- cached element restores the old 1x/frame cost of the _activeEventElement - -- mechanism that task 05 replaced. Cache auto-invalidates when the - -- topElements table reference changes (so tests and mid-frame rebuilds get - -- fresh results) and is cleared explicitly per-frame in flexlove.update. - local cache = Context._interactiveLookupCache - if - cache.valid - and cache.x == x - and cache.y == y - and cache.topElementsRef == Context.topElements - and cache.frameNumber == Context._frameNumber - then - return cache.result - end - - local interactiveCandidates = {} - - local function collectInteractive(element, scrollOffsetX, scrollOffsetY) - scrollOffsetX = scrollOffsetX or 0 - scrollOffsetY = scrollOffsetY or 0 - - if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then - return - end - - -- Check if this element is interactive - if element.onEvent or element.themeComponent or element.editable then - table.insert(interactiveCandidates, element) - end - - -- Recurse into children with accumulated scroll offset - local childScrollOffsetX = scrollOffsetX - local childScrollOffsetY = scrollOffsetY - if elementHasScrollableOverflow(element) then - childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0) - childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0) - end - - for _, child in ipairs(element.children) do - collectInteractive(child, childScrollOffsetX, childScrollOffsetY) - end - end - - -- Always traverse the tree (works in both modes — topElements exists always) - for _, element in ipairs(Context.topElements) do - collectInteractive(element) - end - - -- Sort by composite z-index descending — topmost wins. The composite key - -- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering - -- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive - -- topmost element matches the visual draw order. This is critical for the - -- game's multi-window layout: a button inside a z=50 MainMenu window must - -- occlude a button inside a z=0 BottomBar even when both buttons default to - -- own z=0. Sorting by own-z alone (the original implementation) couldn't - -- distinguish them, so the wrong window's button could win, leaving the - -- visible button's isActiveElement=false and clicks/hover dead. - local zIndices = {} - for _, el in ipairs(interactiveCandidates) do - zIndices[el] = getEffectiveZIndex(el) - end - table.sort(interactiveCandidates, function(a, b) - return zIndices[a] > zIndices[b] - end) - - local result = interactiveCandidates[1] - - cache.x = x - cache.y = y - cache.result = result - cache.topElementsRef = Context.topElements - cache.frameNumber = Context._frameNumber - cache.valid = true - - return result -end - ---- Invalidate the per-frame `findInteractiveAtPosition` cache. ---- Called once at the top of `flexlove.update` (the natural per-frame boundary ---- in both modes) and from `clearFrameElements` (immediate-mode mid-frame ---- rebuild). After invalidation the next lookup recomputes fresh. -function Context.clearInteractiveCache() - local cache = Context._interactiveLookupCache - cache.valid = false - cache.x = nil - cache.y = nil - cache.result = nil - cache.topElementsRef = nil - cache.frameNumber = -1 -end - ---- Set the focused element (centralizes focus management) ---- Automatically blurs the previously focused element if different ----@param element Element|nil The element to focus (nil to clear focus) -function Context.setFocused(element) - if Context._focusedElement == element then - return -- Already focused - end - - -- Prevent re-entry during focus change - if Context._settingFocus then - return - end - Context._settingFocus = true - - -- Save reference to previously focused element before updating - local oldFocusedElement = Context._focusedElement - - -- Blur previously focused element - if oldFocusedElement and oldFocusedElement ~= element then - if oldFocusedElement._textEditor then - oldFocusedElement._textEditor:blur(oldFocusedElement) - end - end - - -- Set new focused element and persist its id for immediate-mode rehydration - Context._focusedElement = element - Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil - - -- Notify any registered focus change hook (e.g. FocusIndicator) - if Context._onFocusChanged then - Context._onFocusChanged(element) - end - - -- Focus the new element's text editor if it has one - if element and element._textEditor then - element._textEditor._focused = true - end - - Context._settingFocus = false -end - ---- Recursively search for an element by id in an element tree ----@param root Element The root element to start searching from ----@param targetId string The id to search for ----@return Element|nil The element with the matching id, or nil if not found -local function findElementById(root, targetId) - if root.id == targetId then - return root - end - for _, child in ipairs(root.children or {}) do - local found = findElementById(child, targetId) - if found then - return found - end - end - return nil -end - ---- Rehydrate _focusedElement from _focusedElementId by scanning live elements. ---- Called at the start of getFocused() in immediate mode so stale references ---- are always replaced with the current-frame object before use. -function Context._rehydrateFocus() - if not Context._focusedElementId then - Context._focusedElement = nil - return - end - - -- First, try a fast linear search through all registered elements - for _, elem in ipairs(Context._zIndexOrderedElements) do - if elem.id == Context._focusedElementId then - Context._focusedElement = elem - return - end - end - - -- If not found, recursively search from top-level elements - -- This handles cases where elements may not be in _zIndexOrderedElements - for _, topLevel in ipairs(Context.topElements or {}) do - local found = findElementById(topLevel, Context._focusedElementId) - if found then - Context._focusedElement = found - return - end - end - - -- Element with that id is not present this frame (e.g. screen changed) - Context._focusedElement = nil -end - ---- Get the currently focused element ----@return Element|nil The focused element, or nil if none -function Context.getFocused() - if Context.isImmediateMode() then - Context._rehydrateFocus() - end - return Context._focusedElement -end - ---- Clear focus from any element -function Context.clearFocus() - Context._focusedElementId = nil - Context.setFocused(nil) -end - ---- Get all focusable elements in tab order, regardless of mode. ---- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted). ---- In retained mode it walks the element tree (DOM order). ---- In both modes, display:none elements are excluded. ----@return table List of focusable elements in tab order -function Context.getFocusableElements() - local focusable = {} - - local function isFocusable(elem) - if elem.display == false then - return false - end - -- Use Element:isFocusable() for consistent behavior - return Element.isFocusable(elem) - end - - local function collectFromTree(elements) - for _, elem in ipairs(elements) do - if isFocusable(elem) then - table.insert(focusable, elem) - end - if #elem.children > 0 then - collectFromTree(elem.children) - end - end - end - - if Context._immediateMode then - -- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first), - -- which approximates tab order for most UIs. - for _, elem in ipairs(Context._zIndexOrderedElements) do - if isFocusable(elem) then - table.insert(focusable, elem) - end - end - else - -- Retained mode: walk the top element trees in DOM order - collectFromTree(Context.topElements) - end - - return focusable -end - --- ==================== --- Navigation Context --- ==================== - ---- Push current focus onto stack (for modals/dialogs) ----@param element Element? -function Context.pushFocusStack(element) - Context._navigationContext.lastFocusedElement = Context._focusedElement - if element then - Context.setFocused(element) - end -end - ---- Pop focus from stack (return from modal) ----@return Element? -function Context.popFocusStack() - local previous = Context._navigationContext.lastFocusedElement - Context._navigationContext.lastFocusedElement = nil - Context.setFocused(previous) - return previous -end - ---- Set navigation container (scope for tab navigation) ----@param element Element? -function Context.setNavigationContainer(element) - Context._navigationContext.containerElement = element -end - ---- Get navigation container ----@return Element? -function Context.getNavigationContainer() - return Context._navigationContext.containerElement -end - -return Context diff --git a/libs/flexlove/modules/Element.lua b/libs/flexlove/modules/Element.lua deleted file mode 100644 index bdf47c63..00000000 --- a/libs/flexlove/modules/Element.lua +++ /dev/null @@ -1,3920 +0,0 @@ ----@class Element ----@field id string ----@field children Element[] ----@field parent Element|nil ----@field userdata any|nil ----@field onEvent fun(self: Element, event: table)|nil ----@field onEventDeferred boolean|nil ----@field onFocus fun(self: Element)|nil ----@field onFocusDeferred boolean ----@field dropFocusOnSelection boolean|nil ----@field onBlur fun(self: Element)|nil ----@field onBlurDeferred boolean ----@field onTextInput fun(self: Element, text: string)|nil ----@field onTextInputDeferred boolean ----@field onTextChange fun(self: Element, text: string)|nil ----@field onTextChangeDeferred boolean ----@field onEnter fun(self: Element)|nil ----@field onEnterDeferred boolean ----@field customDraw fun(self: Element)|nil ----@field onTouchEvent fun(self: Element, event: table)|nil ----@field onTouchEventDeferred boolean ----@field onGesture fun(self: Element, gesture: table)|nil ----@field onGestureDeferred boolean ----@field touchEnabled boolean ----@field multiTouchEnabled boolean ----@field theme table|nil ----@field themeComponent string|nil ----@field disabled boolean ----@field active boolean ----@field disableHighlight boolean ----@field contentAutoSizingMultiplier number[]|nil ----@field scaleCorners boolean|nil ----@field scalingAlgorithm string|nil ----@field contentBlur {radius:number, quality?:number}|nil ----@field backdropBlur {radius:number, quality?:number}|nil ----@field editable boolean ----@field multiline boolean ----@field passwordMode boolean ----@field textWrap string|boolean ----@field maxLines number|nil ----@field maxLength number|nil ----@field placeholder string|nil ----@field inputType string ----@field textOverflow string ----@field scrollable boolean ----@field autoGrow boolean ----@field selectOnFocus boolean ----@field cursorColor Color|nil ----@field selectionColor Color|nil ----@field cursorBlinkRate number ----@field selectParent Element|nil ----@field selectOption table|nil ----@field onChange fun(self: Element, value: any, option: Element)|nil ----@field border number|table|nil ----@field borderColor Color ----@field backgroundColor Color ----@field opacity number ----@field visibility string ----@field display boolean ----@field transform table|nil ----@field cornerRadius number|{topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|nil ----@field text string|nil ----@field textAlign string|table|nil ----@field textAlignHorizontal string ----@field textAlignVertical string ----@field imagePath string|nil ----@field image table|nil ----@field objectFit string ----@field objectPosition string ----@field imageOpacity number ----@field imageRepeat string ----@field imageTint Color|nil ----@field onImageLoad fun(self: Element, image: table)|nil ----@field onImageLoadDeferred boolean ----@field onImageError fun(self: Element, err: string)|nil ----@field onImageErrorDeferred boolean ----@field prevGameSize {width:number, height:number} ----@field autosizing {width:boolean, height:boolean} ----@field units table ----@field minTextSize number|nil ----@field maxTextSize number|nil ----@field autoScaleText boolean ----@field fontFamily string|nil ----@field textSize number ----@field width number ----@field height number ----@field x number ----@field y number ----@field z number ----@field gap number ----@field flexGrow number ----@field flexShrink number ----@field flexBasis number|string ----@field padding {top:number, right:number, bottom:number, left:number} ----@field margin {top:number, right:number, bottom:number, left:number} ----@field tabIndex number|nil ----@field textColor Color ----@field positioning string ----@field top number|nil ----@field right number|nil ----@field bottom number|nil ----@field left number|nil ----@field flexDirection string|nil ----@field flexWrap string|nil ----@field justifyContent string|nil ----@field alignItems string|nil ----@field alignContent string|nil ----@field justifySelf string|nil ----@field alignSelf string ----@field gridRows number|nil ----@field gridColumns number|nil ----@field columnGap number|nil ----@field rowGap number|nil ----@field transition table ----@field transitions table|nil ----@field animation table|nil ----@field overflow string|nil ----@field overflowX string|nil ----@field overflowY string|nil ----@field scrollbarWidth number|nil ----@field scrollbarColor Color|nil ----@field scrollbarBackgroundColor Color|nil ----@field scrollbarTrackColor Color|nil ----@field scrollbarRadius number|nil ----@field scrollbarPadding number|nil ----@field scrollSpeed number|nil ----@field invertScroll boolean|nil ----@field scrollBarStyle string|nil ----@field scrollbarKnobOffset number|nil ----@field hideScrollbars boolean|nil ----@field scrollbarPlacement string|nil ----@field scrollbarBalance number|nil ----@field borderWidth number|nil ----@field fontSize number|nil ----@field lineHeight number|nil -local Element = {} -Element.__index = Element - --- Forward declarations for the special-handler binding helpers used by --- Element:_applyProps (behavior-mode-unification task 08 capstone). These --- absorb the former subsystem-init and visual-state phase bodies (ThemeManager --- creation + theme-field exposure + editable/text/scroll/autoGrow/select-field --- defaults + parent assignment, and border/cornerRadius/display/text/textAlign --- normalization). They are now private implementation of the props-binding phase --- rather than standalone Element methods, so there are no longer per-capability --- init phases on Element. -local bindThemeAndFields, bindVisualState - --- NOTE: There is intentionally NO custom Element.__newindex for dimension properties. --- Lua's __newindex fires ONLY when the key is ABSENT from the raw table, but width/ --- height/x/y are all assigned during Element.new, so they already exist post- --- construction. A __newindex handler therefore CANNOT intercept retained-mode bare --- writes like `element.width = "42%"` (it just rawsets the broken string). --- Dimensions are instead validated lazily in Element:_checkDimensionTypes() at the --- start of each reflow, and must be changed via :setProperty() for resolution + --- layout invalidation. Keeping the metatable free of __newindex also avoids a --- per-field-write function call on every absent-key assignment (perf). - -local MAX_DEFER_RETRIES = 10 -local MAX_DEFERRED_METHODS = 100 -local _DEFERRED_NIL = {} -local unpack = table.unpack or unpack - ----Initialize Element module with required dependencies ----@param deps table Dependency table containing all required modules -function Element.init(deps) - Element._ErrorHandler = deps.ErrorHandler - Element._Color = deps.Color - Element._Context = deps.Context - Element._Units = deps.Units - Element._Calc = deps.Calc - Element._utils = deps.utils - Element._InputEvent = deps.InputEvent - Element._EventHandler = deps.EventHandler - Element._Renderer = deps.Renderer - Element._LayoutEngine = deps.LayoutEngine - Element._TextEditor = deps.TextEditor - Element._ScrollManager = deps.ScrollManager - Element._Theme = deps.Theme - Element._RoundedRect = deps.RoundedRect - Element._NinePatch = deps.NinePatch - Element._ImageRenderer = deps.ImageRenderer - Element._ImageCache = deps.ImageCache - Element._ImageScaler = deps.ImageScaler - Element._Blur = deps.Blur - Element._Transform = deps.Transform - Element._Grid = deps.Grid - Element._StateManager = deps.StateManager - Element._GestureRecognizer = deps.GestureRecognizer - Element._Performance = deps.Performance - Element._Animation = deps.Animation - Element._ZIndex = deps.ZIndex - Element._Select = deps.Select - Element._PropertySchema = deps.PropertySchema or require("modules.PropertySchema") - Element._Select.init({ - ErrorHandler = Element._ErrorHandler, - Context = Element._Context, - StateManager = Element._StateManager, - utils = Element._utils, - Element = Element, - }) - Element._ScrollManager.init({ - ErrorHandler = Element._ErrorHandler, - Context = Element._Context, - StateManager = Element._StateManager, - }) - - -- Bind Element scroll/scrollbar API directly onto ScrollManager. - -- ScrollManager owns all scroll interaction logic; Element retains only - -- 1-line delegates (no hand-written sync/nil-guard boilerplate). - local SM = Element._ScrollManager - Element._syncScrollManagerState = SM.syncToElement - Element._detectOverflow = SM._detectOverflow - Element.setScrollPosition = SM.setScrollPosition - Element._calculateScrollbarDimensions = SM._calculateScrollbarDimensions - Element._getScrollbarAtPosition = SM._getScrollbarAtPosition - Element._handleScrollbarPress = SM._handleScrollbarPress - Element._handleScrollbarDrag = SM._handleScrollbarDrag - Element._handleScrollbarRelease = SM._handleScrollbarRelease - Element._handleWheelScroll = SM._handleWheelScroll - Element.getScrollPosition = SM.getScrollPosition - Element.getMaxScroll = SM.elementGetMaxScroll - Element.getScrollPercentage = SM.elementGetScrollPercentage - Element.hasOverflow = SM.elementHasOverflow - Element.getContentSize = SM.elementGetContentSize - Element.scrollBy = SM.elementScrollBy - Element.scrollToTop = SM.scrollToTop - Element.scrollToBottom = SM.scrollToBottom - Element.scrollToLeft = SM.scrollToLeft - Element.scrollToRight = SM.scrollToRight - - -- Hoist subsystem dependency tables: created once at init time, not rebuilt - -- per Element.new() call. Staged initializers reference these directly. - Element._eventHandlerDeps = { - InputEvent = Element._InputEvent, - Context = Element._Context, - utils = Element._utils, - } - - -- Behavior registry (behavior-mode-unification). Concrete behaviors live in - -- modules/behaviors/ and auto-attach during Element.new when their - -- shouldAttach(props) predicate returns true. Element.update/draw/save-restore - -- dispatch over `element.behaviors` instead of branching on capability flags. - -- Registry order matters for onDraw layering: Themed (core Renderer:draw) must - -- run before Clickable (pressed overlay) so pressed feedback paints on top. - -- Imageable (image config) runs last. Animated (task 06) is late-attach-only. - -- Task 02 wires Clickable; task 05 Selectable; task 06 Animated; task 07 - -- Themed + Imageable; task 04 TextEditable (cursor blink + TextEditor - -- ownership + the 27 text-delegate forwarders). Scrollable is pending. - Element._behaviorRegistry = deps.behaviors or deps.clickableBehaviors or {} - -- TextEditable module reference: Element's 1-line text-delegate forwarders - -- route through `Element._TextEditable.(self, ...)` (task 04). Resolved - -- from deps (wired by FlexLove alongside the behavior registry) so minimal - -- builds without TextEditable leave forwarders inert (guarded by their - -- callers / the behavior's nil-checks). - Element._TextEditable = deps.TextEditable - -- Cached lookup of the Animated behavior instance for late-attach. Resolved - -- lazily (behaviors are optional in minimal builds) the first time an - -- animation is created on an element. - Element._animatedBehavior = nil - Element._rendererDeps = { - Color = Element._Color, - RoundedRect = Element._RoundedRect, - NinePatch = Element._NinePatch, - ImageRenderer = Element._ImageRenderer, - ImageCache = Element._ImageCache, - Theme = Element._Theme, - Blur = Element._Blur, - Transform = Element._Transform, - utils = Element._utils, - } - Element._layoutEngineDeps = { - utils = Element._utils, - Grid = Element._Grid, - Units = Element._Units, - Context = Element._Context, - ErrorHandler = Element._ErrorHandler, - } - Element._textEditorDeps = { - Context = Element._Context, - StateManager = Element._StateManager, - Color = Element._Color, - utils = Element._utils, - } - Element._scrollManagerDeps = { - utils = Element._utils, - Color = Element._Color, - } -end - --- Module-level helper: resolve a dimensional property with CSS-like unit support (px, %, vw, vh, calc) --- Defined once (not inside new()) to avoid per-element closure allocation. --- Handles parsing, defensive checks, and storage in both self and self.units tables. ----@param self table Element instance ----@param raw any Raw property value (string, number, CalcObject, or nil) ----@param key string Field name on self and self.units (e.g., "width", "x") ----@param ref number Reference dimension for percentage resolution ----@param ctx {vw:number, vh:number, sx:number, sy:number} Viewport and scale context ----@param opts {offset?: number, scaleAxis?: "x"|"y", default?: number, nullable?: boolean}? ----@return number? resolved Resolved pixel value (or nil if opts.nullable and input is missing/invalid) -local function _resolveUnit(self, raw, key, ref, ctx, opts) - opts = opts or {} - if raw == nil then - if opts.nullable then - return nil - end - local default = opts.default or 0 - self[key] = (opts.offset or 0) + default - self.units[key] = { value = default, unit = "px" } - return self[key] - end - local isCalc = Element._Calc and Element._Calc.isCalc(raw) - if type(raw) == "string" or isCalc then - local value, unit = Element._Units.parse(raw) - local resolved = Element._Units.resolve(value, unit, ctx.vw, ctx.vh, ref) - if type(resolved) ~= "number" then - if opts.nullable then - return nil - end - Element._ErrorHandler:warn("Element", "LAY_003", { - issue = key .. " resolution returned non-number value", - type = type(resolved), - value = tostring(resolved), - }) - resolved = 0 - end - self.units[key] = { value = value, unit = unit } - self[key] = (opts.offset or 0) + resolved - else - local val = raw - if opts.scaleAxis and Element._Context.baseScale then - val = raw * (opts.scaleAxis == "x" and ctx.sx or ctx.sy) - end - self[key] = (opts.offset or 0) + val - self.units[key] = { value = raw, unit = "px" } - end - return self[key] -end - --- Module-level helper: re-resolve a stored unit spec against a new viewport/parent reference. --- Used by resize() to refresh min/max constraints declared with %/vw/vh units. -local function _refreshUnit(self, key, ref, ctx, scaleAxis) - local u = self.units[key] - if not u or u.value == nil then - return - end - if u.unit == "px" then - self[key] = Element._Context.baseScale and (u.value * (scaleAxis == "x" and ctx.sx or ctx.sy)) or u.value - return - end - local resolved = Element._Units.resolve(u.value, u.unit, ctx.vw, ctx.vh, ref) - self[key] = type(resolved) == "number" and resolved or nil -end - --- --------------------------------------------------------------------------- --- Consolidated warn helpers (Task 11). --- Each duplicated "expecting X, got Y" / guard instrumentation block lived at --- its own use site; these single-reference helpers centralize the emit so call --- sites are thin invocations. Validation semantics (warn+fallback vs. throw) are --- preserved exactly — instrumentation is consolidated, not deleted. --- --------------------------------------------------------------------------- - --- Emit a VAL_001 invalid-enum warn for a textAlign sub-field and return the --- fallback. textAlign's schema entry is type "any" (string | table | compound), --- so this IS the boundary validator for the 4 textAlign parse branches in the --- props-phase visual-state helper. -local function _warnTextAlign(field, expected, got, fallback) - Element._ErrorHandler:warn("Element", "VAL_001", { - property = field, - expected = expected, - got = tostring(got), - }) - return fallback -end - --- Emit a FLEX_00x warn for an invalid flexGrow/flexShrink/flexBasis and return --- the fallback value. These props are SPECIAL_PROPS (warn+fallback, not throw) --- so this is their boundary validator. -local function _warnFlexInvalid(self, code, issue, value, fallback) - Element._ErrorHandler:warn("Element", code, { - element = self.id or "unnamed", - issue = issue, - value = tostring(value), - }) - return fallback -end - --- Emit an ELEM_010/011/012 warn for malformed declarative children entries. -local function _warnChildrenInvalid(self, code, issue, value) - local details = { element = self.id or "unnamed", issue = issue } - if value ~= nil then - details.value = tostring(value) - end - Element._ErrorHandler:warn("Element", code, details) -end - --- Emit LAY_011 when CSS positioning props (top/right/bottom/left) are supplied --- without absolute positioning. Called from both the no-parent and with-parent --- branches of _initPositioning. -local function _warnCssPositioningWithoutAbsolute(self, props) - local properties = {} - if props.top then - table.insert(properties, "top") - end - if props.bottom then - table.insert(properties, "bottom") - end - if props.left then - table.insert(properties, "left") - end - if props.right then - table.insert(properties, "right") - end - Element._ErrorHandler:warn("Element", "LAY_011", { - element = self.id or "unnamed", - positioning = self._originalPositioning or "relative", - properties = table.concat(properties, ", "), - }) -end - --- Emit an ELEM_003/004/005 guard warn for the animation/transition public API --- (deps-missing, non-table arg, invalid duration, non-table property list). --- All warn + fall back rather than throw. `value` nil => warn carries no details. -local function _warnAnimApi(code, value) - if value ~= nil then - Element._ErrorHandler:warn("Element", code, { value = tostring(value) }) - else - Element._ErrorHandler:warn("Element", code) - end -end - --- Image loading + image callback firing now live in the Imageable behavior --- (modules/behaviors/Imageable.lua) — moved out of Element per --- behavior-mode-unification task 07. Element is decoupled from image concern; --- the Imageable behavior enriches `element._renderer` with image config, runs --- the deferred load pipeline, and persists `_loadedImage` across immediate-mode --- frames. The fire-callback helper (formerly `_fireImageCallback` here) is --- reproduced inside Imageable as `fireImageCallback`. - --- --------------------------------------------------------------------------- --- Data-driven prop binding (Task 03) --- --------------------------------------------------------------------------- --- SPECIAL_PROPS is the documented boundary of the schema-driven _applyProps --- loop. Props listed here are bound explicitly by bindThemeAndFields / --- bindVisualState / the staged initializers instead of the generic registry --- loop, for one of five load-bearing reasons (none represent unfinished --- migration — moving them into the generic loop would require extending the --- PropertySchema DSL, which is deliberately kept small and declarative): --- --- 1. SUBSYSTEM ORDERING — the prop needs a subsystem constructed first. --- Theme props (theme/themeComponent/disabled/active/...) depend on the --- ThemeManager being alive so their defaults can be read from it; the --- generic loop runs before subsystem creation in _attachBehaviors. --- --- 2. NON-LITERAL DEFAULTS — the default is not a static value the schema's --- `default:` field can express. borderColor/backgroundColor/textColor --- default to Color.new(...); text defaults to "" only when editable; --- scrollable/autoGrow default from multiline. The schema only stores --- literal defaults (pure-Lua constraint; see PropertySchema.lua header). --- --- 3. UNIT RESOLUTION / VIEWPORT CONTEXT — dimension props (width/height/x/y --- /gap/padding/...) accept unit strings ("50%", "10px") or CalcObjects that --- resolve against parent size and viewport, which a pure-Lua schema cannot --- see. setProperty routes these via the `isDimension` flag at runtime, but --- construction-time binding needs the sizing context from _initSizingContext. --- --- 4. WARN-AND-FALLBACK vs. THROW — display and the flex props validate with a --- non-throwing warn+fallback path; the schema's `validator` field throws --- (VAL_001) for invalid enum/range values. These need their own boundary --- validators (_warnFlexInvalid / the display type-check). --- --- 5. SUBSYSTEM OWNERSHIP — overflow/scrollbar* are owned by ScrollManager, --- selectParent/selectOption by the Select subsystem, border/cornerRadius --- use schema normalizers but bind with a special shape (all-false→nil). --- These props' storage is owned by their subsystem, not the element core. --- --- Adding a new SIMPLE prop requires only a PropertySchema entry; adding a prop --- that needs any of the above additionally requires listing it here and binding --- it in the matching special-handler phase. Props NOT listed here (e.g. --- callbacks, editable, multiline, passwordMode, autoScaleText, cursorColor, --- selectionColor, opacity, visibility, transform, imagePath/objectFit/..., --- minTextSize/maxTextSize, alignSelf, transition) are bound generically by --- _applyProps (defaults + normalizers + validators + onX/onXDeferred --- auto-wiring). -local function _set(...) - local t = {} - for _, name in ipairs({ ... }) do - t[name] = true - end - return t -end - -local SPECIAL_PROPS = _set( - -- identity / tree - "id", - "parent", - "children", - -- theme-driven (ThemeManager owns these / computes defaults) - "theme", - "themeComponent", - "disabled", - "isDisabled", - "active", - "disableHighlight", - "themeStateLock", - "themeComponentDisabledStates", - "scaleCorners", - "scalingAlgorithm", - "contentAutoSizingMultiplier", - -- color defaults that require the Color module - "borderColor", - "backgroundColor", - "textColor", - -- display: non-throwing warn+fallback (unlike throwing range/enum validators) - "display", - -- text editing (validation side-effects / computed defaults) - "textWrap", - "scrollable", - "autoGrow", - "text", - "textAlign", - "textAlignHorizontal", - "textAlignVertical", - "textSize", - "fontFamily", - -- box model / dimensions (unit resolution) - "width", - "height", - "x", - "y", - "z", - "minWidth", - "maxWidth", - "minHeight", - "maxHeight", - "gap", - "top", - "right", - "bottom", - "left", - "columnGap", - "rowGap", - "padding", - "margin", - -- layout enums (positioning-mode validation + LayoutEngine config) - "positioning", - "flexDirection", - "flexWrap", - "justifyContent", - "alignItems", - "alignContent", - "justifySelf", - "gridRows", - "gridColumns", - -- flex shorthand + validated numerics (custom FLEX_xx warnings) - "flex", - "flexGrow", - "flexShrink", - "flexBasis", - -- scroll / scrollbar (ScrollManager owns these) - "overflow", - "overflowX", - "overflowY", - "scrollbarWidth", - "scrollbarColor", - "scrollbarTrackColor", - "scrollbarRadius", - "scrollbarPadding", - "scrollSpeed", - "invertScroll", - "smoothScrollEnabled", - "scrollBarStyle", - "scrollbarKnobOffset", - "hideScrollbars", - "scrollbarPlacement", - "scrollbarBalance", - "_scrollX", - "_scrollY", - -- select (Select subsystem owns these) - "selectParent", - "selectOption", - -- border / cornerRadius use schema normalizers but are bound as special handlers - "border", - "cornerRadius", - -- misc instance-only fields derived during construction - "tabIndex" -) - ---- Bind every schema-driven, side-effect-free prop onto `self` in one pass. ---- Iterates PropertySchema entries: applies defaults, normalizers, validators, ---- and auto-wires `onX` + `onXDeferred` companion pairs. Props listed in ---- SPECIAL_PROPS are skipped (they are handled explicitly in Element.new). ----@param props table Element construction props -function Element:_applyProps(props) - local schema = Element._PropertySchema - local registry = schema.all() - for name, meta in pairs(registry) do - -- Skip deferred companion entries (auto-wired by their base callback's - -- hasDeferred branch below) and SPECIAL_PROPS (handled in Element.new). - if not (name:match("Deferred$") or SPECIAL_PROPS[name]) then - local value = props[name] - if value == nil then - value = meta.default - end - if meta.normalizer then - value = meta.normalizer(value) - end - if meta.validator and value ~= nil and not meta.validator(value) then - -- Mirror the legacy throwing validateRange/validateEnum behavior: invalid - -- enum/range values error during construction (validated props: opacity, - -- imageOpacity, objectFit, imageRepeat). display is a special handler that - -- warns + falls back instead. - Element._ErrorHandler:error("Element", "VAL_001", { - property = name, - expected = meta.type, - got = tostring(value), - }) - end - local key = meta.storageKey or name - self[key] = value - -- Auto-wire deferred companion for callbacks that declare hasDeferred. - if meta.hasDeferred then - local deferredName = name .. "Deferred" - local deferredValue = props[deferredName] - self[deferredName] = deferredValue ~= nil and deferredValue or false - end - end - end - - -- Special-handler binding (behavior-mode-unification task 08 capstone): the - -- props below need side-effects, ordering relative to subsystems, non-literal - -- defaults, or unit resolution, so they cannot be bound by the generic schema - -- loop above. The two helpers below fold in the former subsystem-init phase - -- (ThemeManager creation + theme-field exposure + editable/multiline/ - -- passwordMode validation + textWrap/scrollable/autoGrow defaults + - -- selectParent/selectOption/_selectState + parent assignment) and visual-state - -- phase (border/cornerRadius/display/text/textAlign normalization). Subsystem - -- CREATION (EventHandler / TextEditor / ScrollManager / Renderer) is owned by - -- behavior onAttach hooks dispatched in _attachBehaviors; these helpers only - -- bind FIELDS that the core sizing/box/positioning phases and the behavior - -- onAttach hooks read. - bindThemeAndFields(self, props) - bindVisualState(self, props) -end - ----@param props ElementProps ----@return Element ---- Construct a new Element. Orchestrator only; real work is in the staged ---- initializers below (Task 10). No single phase exceeds ~400 LOC. -function Element.new(props) - -- Staged initializers (behavior-mode-unification task 08 capstone). The - -- orchestrator is a thin dispatcher: it runs core-data phases only - -- (construct → props → sizing → box model → positioning → finalize), then - -- attaches behaviors. The former behavioral phases (subsystem-init, visual- - -- state, image/renderer, scroll-manager) are deleted: their field-binding logic - -- folded into _applyProps and their subsystem creation logic moved into - -- behavior onAttach hooks (Clickable / TextEditable / Selectable / Themed / - -- Imageable / Scrollable). _attachBehaviors runs at the tail so - -- Selectable.onAttach can re-scan declarative children built by - -- _finalizeConstruction and so onAttach sees all element fields bound. - local self = Element:_construct(props) - self:_applyProps(props) - self:_initSizingContext(props) - self:_initBoxModel(props) - self:_initPositioning(props) - self:_finalizeConstruction(props) - self:_attachBehaviors(props) - return self -end - ---- Phase 1: metatable, schema-driven prop normalization (for special-handler ---- props), default tables (children, _deferredMethods), and ID generation. ---- Schema-driven binding (_applyProps) is invoked separately by Element.new. -function Element:_construct(props) - local instance = setmetatable({}, Element) - - -- Stash the construction props so behavior onAttach hooks (which receive only - -- the element per the locked `(element, ...)` signature) can read SPECIAL_PROPS - -- config that is NOT bound onto the element by the schema-driven _applyProps - -- loop (e.g. the scrollbar config consumed by Scrollable.onAttach). Prefixed - -- with `_` so the immediate-mode saveState public-prop scan skips it. - instance._initProps = props - - -- Apply schema-driven shape normalizers to props for the special-handler - -- props (padding/margin/flexDirection) whose downstream unit-resolution logic - -- reads from `props` directly. Generic props are bound by _applyProps below. - local schema = Element._PropertySchema - props.flexDirection = schema.get("flexDirection").normalizer(props.flexDirection) - props.padding = schema.get("padding").normalizer(props.padding) - props.margin = schema.get("margin").normalizer(props.margin) - - -- Behavior registry (Task 01 of behavior-mode-unification). Concrete - -- behaviors (Clickable, Scrollable, ...) are attached here in later tasks; - -- Element:update/draw/save-restore dispatch over this table instead of - -- branching on individual capability flags. Initially empty so existing - -- behavior is identical to pre-refactor until behaviors are wired in. - instance.children = {} - instance.behaviors = {} - instance._deferredMethods = {} - - -- Track whether ID was auto-generated (before ID assignment) - local idWasAutoGenerated = not props.id or props.id == "" - - -- Auto-generate ID if not provided (for all elements) - if idWasAutoGenerated then - instance.id = Element._StateManager.generateID(props, props.parent) - else - instance.id = props.id - end - - -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) - instance._stateId = instance.id - - -- Register with StateManager for state access (both immediate and retained modes) - if instance._stateId and instance._stateId ~= "" then - Element._StateManager.registerStateful(instance._stateId, instance) - end - return instance -end - ---- Attach behaviors whose shouldAttach(props) predicate matches this element's ---- props. Runs after prop binding + subsystem init (so Deferred flags and the ---- Select subsystem are in place) and dispatches onAttach for each match. The ---- EventHandler (Clickable) is created here rather than in the former subsystem- ---- init phase so Element never needs to know what an individual behavior does — ---- it only iterates the registry (behavior-mode-unification task 02). -function Element:_attachBehaviors(props) - local registry = Element._behaviorRegistry - if registry then - for _, behavior in ipairs(registry) do - if behavior.shouldAttach(props) then - table.insert(self.behaviors, behavior) - behavior.onAttach(self) - end - end - end -end - ---- Resolve the (lazily cached) Animated behavior instance from the registry. --- task 09: since the behavior loop no longer excludes Animated, elements WITH --- the behavior attached get the loop dispatch and `_dispatchAnimatedUpdate` no-ops --- for them. --- task 09 consolidation: `_ensureAnimatedAttached` / `_isAnimatedBehavior` --- (dead code) were removed; Animated.ensureAttached remains the late-attach --- entry point for callers that route through it. -function Element._resolveAnimatedBehavior() - local animated = Element._animatedBehavior - if animated == nil then - local registry = Element._behaviorRegistry - if registry then - for _, behavior in ipairs(registry) do - -- Animated exposes ensureAttached; Clickable/Themed/Imageable do not. - if type(behavior.ensureAttached) == "function" then - animated = behavior - break - end - end - end - Element._animatedBehavior = animated or false - end - return animated -end - ---- Dispatch Animated.onUpdate for an element EARLY in Element:update (before ---- the behavior loop) so animated geometry (x/y/width/height) is current for ---- Clickable hit-testing and Scrollable interaction this frame. NO-OPS for ---- elements that already have the Animated behavior attached (the loop ---- dispatches those) to avoid a double update — animation:update(dt) is not ---- idempotent within a frame. This handles the direct-assignment path ---- (`element.animation = ...` / `anim:apply`) that bypasses ---- Animated.ensureAttached; for elements with no animation the behavior's ---- onUpdate reads `element.animation` and returns. Not a behavioral capability ---- branch — iterates `element.behaviors`. (behavior-mode-unification task 09.) -function Element._dispatchAnimatedUpdate(element, dt) - if not element then - return - end - local animated = Element._resolveAnimatedBehavior() - if not animated then - return - end - -- Already attached? The behavior loop will dispatch it; bail to avoid a - -- double update (animation:update advances twice if called twice). - local behaviors = element.behaviors - if behaviors then - for i = 1, #behaviors do - if behaviors[i] == animated then - return - end - end - end - animated.onUpdate(element, dt) -end - ---- Special-handler binding helper for the props phase (behavior-mode- ---- unification task 08). Formerly the Element subsystem-init phase. Binds the ---- ThemeManager (or no-op fallback) + exposes theme fields, validates ---- editable/multiline/passwordMode combos, sets textWrap/scrollable/autoGrow ---- defaults, initializes selectParent/selectOption/_selectState fields (the ---- Select subsystem itself is initialized by Selectable.onAttach), and assigns ---- self.parent. EventHandler creation is owned by Clickable.onAttach and ---- TextEditor creation by TextEditable.onAttach — both run in _attachBehaviors at ---- the tail of Element.new, so this helper does not touch either subsystem. -bindThemeAndFields = function(self, props) - if Element._Theme then - self._themeManager = Element._Theme.Manager.new({ - theme = props.theme or Element._Context.defaultTheme, - themeComponent = props.themeComponent or nil, - disabled = props.isDisabled or props.disabled or false, - active = props.active or false, - disableHighlight = props.disableHighlight, - themeStateLock = props.themeStateLock or false, - themeComponentDisabledStates = props.themeComponentDisabledStates, - scaleCorners = props.scaleCorners, - scalingAlgorithm = props.scalingAlgorithm, - }) - else - -- Theme module absent (minimal build) — plain no-op ThemeManager - local noPadding = { top = 0, right = 0, bottom = 0, left = 0 } - self._themeManager = { - theme = nil, - themeComponent = props.themeComponent or nil, - disabled = props.isDisabled or props.disabled or false, - active = props.active or false, - themeComponentDisabledStates = {}, - scaleCorners = props.scaleCorners, - scalingAlgorithm = props.scalingAlgorithm, - validateThemeStateLock = function() end, - getState = function() - return "normal" - end, - setState = function() end, - updateState = function() - return false - end, - hasThemeComponent = function() - return false - end, - getTheme = function() - return nil - end, - getComponent = function() - return nil - end, - getStateComponent = function() - return nil - end, - getScrollbarComponent = function() - return nil - end, - getDefaultFontFamily = function() - return nil - end, - getContentAutoSizingMultiplier = function() - return nil - end, - getScaledContentPadding = function() - return noPadding - end, - getScaledContentPaddingForState = function() - return noPadding - end, - _getScaledContentPaddingForState = function() - return noPadding - end, - getStyle = function() - return nil - end, - } - end - - -- Validate themeStateLock after ThemeManager is created - if props.themeStateLock and props.themeComponent then - self._themeManager:validateThemeStateLock() - end - - -- Expose theme properties for backward compatibility - self.theme = self._themeManager.theme - self.themeComponent = self._themeManager.themeComponent - self.disabled = self._themeManager.disabled - self.active = self._themeManager.active - self._themeState = self._themeManager:getState() - - -- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback) - -- Can be explicitly overridden by setting props.disableHighlight - if props.disableHighlight ~= nil then - self.disableHighlight = props.disableHighlight - else - self.disableHighlight = self.themeComponent ~= nil - end - - -- Initialize contentAutoSizingMultiplier after theme is set - -- Priority: element props > theme component > theme default - if props.contentAutoSizingMultiplier then - self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier - else - local multiplier = self._themeManager:getContentAutoSizingMultiplier() - self.contentAutoSizingMultiplier = multiplier or { 1, 1 } - end - - -- Expose 9-patch corner scaling properties for backward compatibility - self.scaleCorners = self._themeManager.scaleCorners - self.scalingAlgorithm = self._themeManager.scalingAlgorithm - - self._blurInstance = nil - - -- editable/multiline/passwordMode are bound by _applyProps (default false). - -- Validate combinations: passwordMode disables multiline. - if self.passwordMode and self.multiline then - Element._ErrorHandler:warn("Element", "ELEM_006") - self.multiline = false - elseif self.passwordMode then - self.multiline = false - end - - self.textWrap = props.textWrap - if self.textWrap == nil then - self.textWrap = self.multiline and "word" or false - end - - self.scrollable = props.scrollable - if self.scrollable == nil then - self.scrollable = self.multiline - end - -- autoGrow defaults to true for multiline, false for single-line - if props.autoGrow ~= nil then - self.autoGrow = props.autoGrow - else - self.autoGrow = self.multiline - end - - self.selectParent = nil - self.selectOption = nil - self._selectState = nil - - if type(props.selectParent) == "table" then - self.selectParent = props.selectParent - end - - if type(props.selectOption) == "table" then - self.selectOption = props.selectOption - end - - -- TextEditor creation + immediate-mode state restore is owned by the - -- TextEditable behavior's onAttach (task 04), which runs in _attachBehaviors - -- at the tail of Element.new (after this helper has bound self.text and the - -- schema-driven callback fields). This subsystem-init helper no longer touches - -- the TextEditor. - - -- Set parent first so it's available for size calculations - self.parent = props.parent -end - ---- Special-handler binding helper for the props phase (behavior-mode- ---- unification task 08). Formerly the Element visual-state phase. Normalizes ---- border/cornerRadius/display/text/textAlign. The branch count comment below ---- refers to the 4 textAlign parse branches (table / simple-string / compound- ---- string / invalid). -bindVisualState = function(self, props) - local schema = Element._PropertySchema - ------ add non-hereditary ------ - --- self drawing --- - -- Border shape-normalization via the schema normalizer (special handler: the - -- number-vs-table-vs-nil shape and the all-false→nil collapse are intentional). - self.border = schema.get("border").normalizer(props.border) - self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1) - self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0) - - -- cornerRadius shape-normalization via the schema normalizer (special handler: - -- number-vs-table-vs-nil and the all-zero→nil collapse are intentional). - self.cornerRadius = schema.get("cornerRadius").normalizer(props.cornerRadius) - - -- display: default true; invalid (non-boolean) warns + falls back to true - -- (non-throwing, unlike range/enum validators). - if props.display ~= nil then - if type(props.display) == "boolean" then - self.display = props.display - else - self.display = true - Element._ErrorHandler:warn( - "Element", - "ELEM_010", - "display must be a boolean (true/false), got " .. type(props.display) .. ". Defaulting to true." - ) - end - else - self.display = true - end - - -- For editable elements, default text to empty string if not provided - if self.editable and props.text == nil then - self.text = "" - else - self.text = props.text - end - - -- Validate and set textAlign (supports simple string, compound string, or - -- table format). Enum membership is checked via PropertySchema validators - -- (the valid H/V sets live there, matching the objectFit/imageRepeat pattern); - -- compound-string parsing and warn+fallback stay here because they need - -- ErrorHandler, which the pure-Lua schema cannot depend on. - local textAlignMeta = schema.get("textAlign") - local textVAlignMeta = schema.get("textAlignVertical") - local textAlignDefault = textAlignMeta.default - local vAlignDefault = textVAlignMeta.default - - self.textAlign = props.textAlign or textAlignDefault - self.textAlignHorizontal = textAlignDefault - self.textAlignVertical = vAlignDefault - - if props.textAlign ~= nil then - if type(props.textAlign) == "table" then - -- Table format: {horizontal = "start", vertical = "center"} - local hAlign = props.textAlign.horizontal or textAlignDefault - local vAlign = props.textAlign.vertical or vAlignDefault - - if not textAlignMeta.validator(hAlign) then - hAlign = _warnTextAlign("textAlign.horizontal", "valid TextAlign value", hAlign, textAlignDefault) - end - if not textVAlignMeta.validator(vAlign) then - vAlign = _warnTextAlign("textAlign.vertical", "valid TextAlignVertical value", vAlign, vAlignDefault) - end - - self.textAlignHorizontal = hAlign - self.textAlignVertical = vAlign - elseif type(props.textAlign) == "string" then - if textAlignMeta.validator(props.textAlign) then - -- Known simple TextAlign value (backward compatible) - self.textAlignHorizontal = props.textAlign - self.textAlignVertical = vAlignDefault - else - -- Treat as compound string: "top-left" through "bottom-right" - local parts = {} - for part in props.textAlign:gmatch("[^-]+") do - table.insert(parts, part) - end - - if #parts == 2 then - local verticalMap = { top = "start", center = "center", bottom = "end" } - local horizontalMap = { left = "start", center = "center", right = "end" } - - local vStr = parts[1]:lower() - local hStr = parts[2]:lower() - local resolvedV = verticalMap[vStr] - local resolvedH = horizontalMap[hStr] - - if resolvedV and resolvedH then - self.textAlignHorizontal = resolvedH - self.textAlignVertical = resolvedV - else - _warnTextAlign( - "textAlign", - "valid compound string (e.g., 'top-left', 'center-right')", - props.textAlign, - nil - ) - end - else - _warnTextAlign("textAlign", "valid TextAlign value or compound string", props.textAlign, nil) - end - end - end - end -end - ---- Phase 5 (image + renderer init) is owned by the Themed and Imageable ---- behaviors (modules/behaviors/), attached in _attachBehaviors. Themed.onAttach ---- creates the Renderer (theme/blur config); Imageable.onAttach enriches it with ---- image config + deferred image loading. There is no longer a stub Element ---- phase for this — the behavior onAttach hooks ARE the phase ---- (behavior-mode-unification task 07/08). - ---- Phase 6a: viewport/scale context, LayoutEngine (defaults), unit specs table, ---- fontFamily, and textSize resolution. -function Element:_initSizingContext(props) - --- self positioning --- - local viewportWidth, viewportHeight = Element._Units.getViewport() - - ---- Sizing ---- - local gw, gh = love.window.getMode() - self.prevGameSize = { width = gw, height = gh } - self.autosizing = { width = false, height = false } - - -- Initialize LayoutEngine early with default values for auto-sizing calculations - -- It will be re-configured later with actual layout properties - self._layoutEngine = Element._LayoutEngine.new({ - positioning = Element._utils.enums.Positioning.RELATIVE, - flexDirection = Element._utils.enums.FlexDirection.HORIZONTAL, - flexWrap = Element._utils.enums.FlexWrap.NOWRAP, - justifyContent = Element._utils.enums.JustifyContent.FLEX_START, - alignItems = Element._utils.enums.AlignItems.STRETCH, - alignContent = Element._utils.enums.AlignContent.STRETCH, - gap = 0, - gridRows = 1, - gridColumns = 1, - - columnGap = 0, - rowGap = 0, - }, Element._layoutEngineDeps) - self._layoutEngine:initialize(self) - - -- Store unit specifications for responsive behavior - self.units = { - width = { value = nil, unit = "px" }, - height = { value = nil, unit = "px" }, - x = { value = nil, unit = "px" }, - y = { value = nil, unit = "px" }, - textSize = { value = nil, unit = "px" }, - gap = { value = nil, unit = "px" }, - flexBasis = { value = nil, unit = "auto" }, - padding = { - top = { value = nil, unit = "px" }, - right = { value = nil, unit = "px" }, - bottom = { value = nil, unit = "px" }, - left = { value = nil, unit = "px" }, - horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right - vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom - }, - margin = { - top = { value = nil, unit = "px" }, - right = { value = nil, unit = "px" }, - bottom = { value = nil, unit = "px" }, - left = { value = nil, unit = "px" }, - horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right - vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom - }, - } - - local _, scaleY = Element._Context.getScaleFactors() - - -- minTextSize/maxTextSize/autoScaleText are bound by _applyProps (autoScaleText - -- defaults true). They are needed before textSize processing below. - - -- Handle fontFamily (can be font name from theme or direct path to font file) - -- Priority: explicit props.fontFamily > parent fontFamily > theme default - if props.fontFamily then - -- Explicitly set fontFamily takes highest priority - self.fontFamily = props.fontFamily - elseif self.parent and self.parent.fontFamily then - -- Inherit from parent if parent has fontFamily set - self.fontFamily = self.parent.fontFamily - elseif props.themeComponent then - -- If using themeComponent, try to get default from theme via ThemeManager - local defaultFont = self._themeManager:getDefaultFontFamily() - self.fontFamily = defaultFont and "default" or nil - else - self.fontFamily = nil - end - - -- Handle textSize BEFORE width/height calculation (needed for auto-sizing) - if props.textSize then - if type(props.textSize) == "string" then - -- Check if it's a preset first - local presetValue, presetUnit = Element._utils.resolveTextSizePreset(props.textSize) - local value, unit - - if presetValue then - -- It's a preset, use the preset value and unit - value, unit = presetValue, presetUnit - self.units.textSize = { value = value, unit = unit } - else - -- Not a preset, parse normally - value, unit = Element._Units.parse(props.textSize) - self.units.textSize = { value = value, unit = unit } - end - - -- Resolve textSize based on unit type - if unit == "%" or unit == "vh" then - -- Percentage and vh are relative to viewport height - self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) - elseif unit == "vw" then - -- vw is relative to viewport width - self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) - elseif unit == "px" then - -- Pixel units - self.textSize = value - else - Element._ErrorHandler:error("Element", "ELEM_002", { - unit = unit, - }) - end - else - -- Validate pixel textSize value - if props.textSize <= 0 then - Element._ErrorHandler:error("Element", "ELEM_001", { - value = tostring(props.textSize), - }) - end - - -- Pixel textSize value - if self.autoScaleText and Element._Context.baseScale then - -- With base scaling: store original pixel value and scale relative to base resolution - self.units.textSize = { value = props.textSize, unit = "px" } - self.textSize = props.textSize * scaleY - elseif self.autoScaleText then - -- Without base scaling: convert to viewport units for auto-scaling - -- Calculate what percentage of viewport height this represents - local vhValue = (props.textSize / viewportHeight) * 100 - self.units.textSize = { value = vhValue, unit = "vh" } - self.textSize = props.textSize -- Initial size is the specified pixel value - else - -- No auto-scaling: apply base scaling if set, otherwise use raw value - self.textSize = Element._Context.baseScale and (props.textSize * scaleY) or props.textSize - self.units.textSize = { value = props.textSize, unit = "px" } - end - end - else - -- No textSize specified - use auto-scaling default - if self.autoScaleText and Element._Context.baseScale then - -- With base scaling: use 12px as default and scale - self.units.textSize = { value = 12, unit = "px" } - self.textSize = 12 * scaleY - elseif self.autoScaleText then - -- Without base scaling: default to 1.5vh (1.5% of viewport height) - self.units.textSize = { value = 1.5, unit = "vh" } - self.textSize = (1.5 / 100) * viewportHeight - else - -- No auto-scaling: use 12px with optional base scaling - self.textSize = Element._Context.baseScale and (12 * scaleY) or 12 - self.units.textSize = { value = nil, unit = "px" } - end - end -end - ---- Phase 6b: width/height/min-max/clamp, gap, flex shorthand/grow/shrink/basis, ---- 9-patch/border-box model, and padding/margin resolution + unit storage. -function Element:_initBoxModel(props) - local viewportWidth, viewportHeight = Element._Units.getViewport() - local scaleX, scaleY = Element._Context.getScaleFactors() - local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY } - -- Handle width (both w and width properties, prefer w if both exist) - -- "auto" is treated as content-sized (same as omitting the property), per CSS semantics. - local widthProp = props.width - if widthProp == "auto" then - widthProp = nil - end - local tempWidth -- Temporary width for padding resolution - if widthProp then - local parentWidth = self.parent and self.parent.width or viewportWidth - tempWidth = _resolveUnit(self, widthProp, "width", parentWidth, _ctx, { scaleAxis = "x" }) - else - self.autosizing.width = true - -- Special case: if textWrap is enabled and parent exists, constrain width to parent - -- Text wrapping requires a width constraint, so use parent's content width - if props.textWrap and self.parent and self.parent.width then - tempWidth = self.parent.width - self.width = tempWidth - self.units.width = { value = 100, unit = "%" } -- Mark as parent-constrained - self.autosizing.width = false -- Not truly autosizing, constrained by parent - else - tempWidth = self:calculateAutoWidth() - self.width = tempWidth - self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized - end - end - - -- Handle height (both h and height properties, prefer h if both exist) - -- "auto" is treated as content-sized (same as omitting the property), per CSS semantics. - local heightProp = props.height - if heightProp == "auto" then - heightProp = nil - end - local tempHeight -- Temporary height for padding resolution - if heightProp then - local parentHeight = self.parent and self.parent.height or viewportHeight - tempHeight = _resolveUnit(self, heightProp, "height", parentHeight, _ctx, { scaleAxis = "y" }) - else - self.autosizing.height = true - -- Calculate auto-height without padding first - tempHeight = self:calculateAutoHeight() - self.height = tempHeight - self.units.height = { value = nil, unit = "auto" } -- Mark as auto-sized - end - - local constraintParentW = self.parent and self.parent.width or viewportWidth - local constraintParentH = self.parent and self.parent.height or viewportHeight - _resolveUnit(self, props.minWidth, "minWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true }) - _resolveUnit(self, props.maxWidth, "maxWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true }) - _resolveUnit(self, props.minHeight, "minHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true }) - _resolveUnit(self, props.maxHeight, "maxHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true }) - - if not self.autosizing.width then - self.width = Element._utils.clamp(tempWidth, self.minWidth, self.maxWidth) - tempWidth = self.width - else - self.width = Element._utils.clamp(self.width, self.minWidth, self.maxWidth) - end - if not self.autosizing.height then - self.height = Element._utils.clamp(tempHeight, self.minHeight, self.maxHeight) - tempHeight = self.height - else - self.height = Element._utils.clamp(self.height, self.minHeight, self.maxHeight) - end - - --- child positioning --- - if props.gap then - local flexDir = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL - local isHorizontalDir = flexDir == Element._utils.enums.FlexDirection.HORIZONTAL - or flexDir == Element._utils.enums.FlexDirection.HORIZONTAL_REVERSE - local containerSize = isHorizontalDir and self.width or self.height - _resolveUnit(self, props.gap, "gap", containerSize, _ctx) - else - self.gap = 0 - self.units.gap = { value = 0, unit = "px" } - end - - -- Handle flex shorthand property (sets flexGrow, flexShrink, flexBasis) - if props.flex ~= nil then - local grow, shrink, basis = Element._Units.parseFlexShorthand(props.flex) - - -- Only set individual properties if they weren't explicitly provided - if props.flexGrow == nil then - props.flexGrow = grow - end - if props.flexShrink == nil then - props.flexShrink = shrink - end - if props.flexBasis == nil then - props.flexBasis = basis - end - end - - -- Track whether flex-shrink was explicitly provided (directly or via flex shorthand) - self._hasExplicitFlexShrink = props.flexShrink ~= nil - - -- Handle flexGrow property - if props.flexGrow ~= nil then - if type(props.flexGrow) == "number" and props.flexGrow >= 0 then - self.flexGrow = props.flexGrow - else - self.flexGrow = _warnFlexInvalid(self, "FLEX_001", "flexGrow must be a non-negative number", props.flexGrow, 0) - end - else - self.flexGrow = 0 - end - - -- Handle flexShrink property - if props.flexShrink ~= nil then - if type(props.flexShrink) == "number" and props.flexShrink >= 0 then - self.flexShrink = props.flexShrink - else - self.flexShrink = - _warnFlexInvalid(self, "FLEX_002", "flexShrink must be a non-negative number", props.flexShrink, 1) - end - else - self.flexShrink = 1 - end - - -- Handle flexBasis property - if props.flexBasis ~= nil then - local isCalc = Element._Calc and Element._Calc.isCalc(props.flexBasis) - if props.flexBasis == "auto" then - self.flexBasis = "auto" - self.units.flexBasis = { value = nil, unit = "auto" } - elseif type(props.flexBasis) == "string" or isCalc then - local value, unit = Element._Units.parse(props.flexBasis) - self.units.flexBasis = { value = value, unit = unit } - -- Don't resolve yet - LayoutEngine will handle this during layout - self.flexBasis = props.flexBasis - elseif type(props.flexBasis) == "number" then - self.flexBasis = props.flexBasis - self.units.flexBasis = { value = props.flexBasis, unit = "px" } - else - self.flexBasis = - _warnFlexInvalid(self, "FLEX_003", "flexBasis must be a number, string, or 'auto'", props.flexBasis, "auto") - self.units.flexBasis = { value = nil, unit = "auto" } - end - else - self.flexBasis = "auto" - self.units.flexBasis = { value = nil, unit = "auto" } - end - - -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions - -- For explicit sizing, width/height already include padding (border-box) - - -- Check if we should use 9-patch content padding for auto-sizing - local use9PatchPadding = false - local ninePatchContentPadding = nil - if self._themeManager:hasThemeComponent() then - local component = self._themeManager:getComponent() - if component and component._ninePatchData and component._ninePatchData.contentPadding then - -- Only use 9-patch padding if no explicit padding was provided - if - not props.padding - or ( - not props.padding.top - and not props.padding.right - and not props.padding.bottom - and not props.padding.left - and not props.padding.horizontal - and not props.padding.vertical - ) - then - use9PatchPadding = true - ninePatchContentPadding = component._ninePatchData.contentPadding - end - end - end - - -- First, resolve padding using temporary dimensions - -- For auto-sized elements, this is content width; for explicit sizing, this is border-box width - local tempPadding - if use9PatchPadding then - -- tempWidth/tempHeight are guaranteed numbers by _resolveUnit (which warns + - -- clamps non-numbers) and calculateAutoWidth/Height; the prior defensive - -- re-check duplicated that boundary validation (Task 11). - - -- Get scaled 9-patch content padding from ThemeManager - local scaledPadding = self._themeManager:getScaledContentPadding(tempWidth, tempHeight) - if scaledPadding then - tempPadding = scaledPadding - else - -- Fallback if scaling fails - tempPadding = { - left = ninePatchContentPadding.left, - top = ninePatchContentPadding.top, - right = ninePatchContentPadding.right, - bottom = ninePatchContentPadding.bottom, - } - end - else - tempPadding = Element._Units.resolveSpacing(props.padding, self.width, self.height) - end - - -- Margin percentages are relative to parent's dimensions (CSS spec) - local parentWidth = self.parent and self.parent.width or viewportWidth - local parentHeight = self.parent and self.parent.height or viewportHeight - self.margin = Element._Units.resolveSpacing(props.margin, parentWidth, parentHeight) - - -- For auto-sized elements, add padding to get border-box dimensions - if self.autosizing.width then - self._borderBoxWidth = self.width + tempPadding.left + tempPadding.right - else - -- For explicit sizing, width is already border-box - self._borderBoxWidth = self.width - end - - if self.autosizing.height then - self._borderBoxHeight = self.height + tempPadding.top + tempPadding.bottom - else - -- For explicit sizing, height is already border-box - self._borderBoxHeight = self.height - end - - -- Set final padding - if use9PatchPadding then - -- Use 9-patch content padding - self.padding = { - left = ninePatchContentPadding.left, - top = ninePatchContentPadding.top, - right = ninePatchContentPadding.right, - bottom = ninePatchContentPadding.bottom, - } - else - -- Re-resolve padding based on final border-box dimensions (important for percentage padding) - self.padding = Element._Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight) - end - - -- Calculate final content dimensions by subtracting padding from border-box - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - - -- Re-resolve textSize presets now that width/height are set - -- (presets like "vw" need the viewport; others are resolved during constructor) - - -- Apply min/max constraints (also scaled) - local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) - - if minSize and self.textSize < minSize then - self.textSize = minSize - end - if maxSize and self.textSize > maxSize then - self.textSize = maxSize - end - - -- Protect against too-small text sizes (minimum 1px) - if self.textSize < 1 then - self.textSize = 1 -- Minimum 1px - end - - -- Store original spacing values for proper resize handling - -- Store spacing unit specs (padding + margin share identical structure) - local sides = { "top", "right", "bottom", "left" } - for _, kind in ipairs({ "padding", "margin" }) do - local src = props[kind] - if src then - for _, axis in ipairs({ "horizontal", "vertical" }) do - if src[axis] then - if type(src[axis]) == "string" then - local value, unit = Element._Units.parse(src[axis]) - self.units[kind][axis] = { value = value, unit = unit } - else - self.units[kind][axis] = { value = src[axis], unit = "px" } - end - end - end - end - for _, side in ipairs(sides) do - if src and src[side] then - if type(src[side]) == "string" then - local value, unit = Element._Units.parse(src[side]) - self.units[kind][side] = { value = value, unit = unit, explicit = true } - else - self.units[kind][side] = { value = src[side], unit = "px", explicit = true } - end - else - self.units[kind][side] = { value = self[kind][side], unit = "px", explicit = false } - end - end - end - - -- Grid properties are set later in the constructor -end - ---- Phase 7: hereditary positioning (no-parent and with-parent), flex/grid ---- container properties, select-frame adopt, and LayoutEngine config update. -function Element:_initPositioning(props) - local viewportWidth, viewportHeight = Element._Units.getViewport() - local scaleX, scaleY = Element._Context.getScaleFactors() - local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY } - ------ add hereditary ------ - if props.parent == nil then - table.insert(Element._Context.topElements, self) - - -- Handle x position with units - _resolveUnit(self, props.x, "x", viewportWidth, _ctx, { scaleAxis = "x", default = 0 }) - - -- Handle y position with units - _resolveUnit(self, props.y, "y", viewportHeight, _ctx, { scaleAxis = "y", default = 0 }) - - self.z = Element._ZIndex.clamp(props.z or 0) - self.tabIndex = props.tabIndex -- nil/0 = document order, >0 = explicit order, -1 = excluded from keyboard nav - - -- Set textColor with priority: props > theme text color > black - if props.textColor then - self.textColor = props.textColor - else - -- Try to get text color from theme via ThemeManager - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.colors and themeToUse.colors.text then - self.textColor = themeToUse.colors.text - else - -- Fallback to black - self.textColor = Element._Color.new(0, 0, 0, 1) - end - end - - -- Track if positioning was explicitly set - if props.positioning then - Element._utils.validateEnum(props.positioning, Element._utils.enums.Positioning, "positioning") - self.positioning = props.positioning - self._originalPositioning = props.positioning - self._explicitlyAbsolute = (props.positioning == Element._utils.enums.Positioning.ABSOLUTE) - else - self.positioning = Element._utils.enums.Positioning.RELATIVE - self._originalPositioning = nil -- No explicit positioning - self._explicitlyAbsolute = false - end - - -- Handle positioning properties for elements without parent - -- Warn if CSS positioning properties are supplied but will be ignored. - -- Relative elements honor the offsets as visual deltas (see - -- _applyRelativeOffsets); absolute elements use applyPositioningOffsets. - -- Only flex-participating children (positioning coerced to ABSOLUTE but not - -- explicitly absolute) actually drop the offsets and warrant the warning. - if - (props.top or props.bottom or props.left or props.right) - and not self._explicitlyAbsolute - and self.positioning ~= Element._utils.enums.Positioning.RELATIVE - then - _warnCssPositioningWithoutAbsolute(self, props) - end - - -- Handle top/right/bottom/left positioning with units - if props.top then - _resolveUnit(self, props.top, "top", viewportHeight, _ctx) - end - if props.right then - _resolveUnit(self, props.right, "right", viewportWidth, _ctx) - end - if props.bottom then - _resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx) - end - if props.left then - _resolveUnit(self, props.left, "left", viewportWidth, _ctx) - end - - -- position: relative offsets are applied as visual deltas in - -- LayoutEngine:layoutChildren (after the flex flow places children), so - -- they survive the addChild -> layoutChildren re-entry here. - else - -- Set positioning first and track if explicitly set - self._originalPositioning = props.positioning -- Track original intent - if props.positioning == Element._utils.enums.Positioning.ABSOLUTE then - self.positioning = Element._utils.enums.Positioning.ABSOLUTE - self._explicitlyAbsolute = true -- Explicitly set to absolute by user - elseif props.positioning == Element._utils.enums.Positioning.FLEX then - self.positioning = Element._utils.enums.Positioning.FLEX - self._explicitlyAbsolute = false - elseif props.positioning == Element._utils.enums.Positioning.GRID then - self.positioning = Element._utils.enums.Positioning.GRID - self._explicitlyAbsolute = false - else - -- Default: children in flex/grid containers participate in parent's layout - -- children in relative/absolute containers default to relative - if - self.parent.positioning == Element._utils.enums.Positioning.FLEX - or self.parent.positioning == Element._utils.enums.Positioning.GRID - then - self.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid - self._explicitlyAbsolute = false -- Participate in parent's layout - else - self.positioning = Element._utils.enums.Positioning.RELATIVE - self._explicitlyAbsolute = false -- Default for relative/absolute containers - end - end - - -- Set initial position - local parentPadding = self.parent.padding or { left = 0, top = 0 } - if self.positioning == Element._utils.enums.Positioning.ABSOLUTE then - -- Absolute positioning is relative to parent's content area (padding box) - local baseX = self.parent.x + parentPadding.left - local baseY = self.parent.y + parentPadding.top - - -- Handle x/y position with units - _resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 }) - _resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 }) - - self.z = Element._ZIndex.clamp(props.z or 0) - self.tabIndex = props.tabIndex - else - -- Children in flex containers start at parent position but will be repositioned by layoutChildren - -- Children in absolute/relative containers start at parent's content area (accounting for padding) - local baseX = self.parent.x + parentPadding.left - local baseY = self.parent.y + parentPadding.top - - -- Warn if explicit x/y is set on a child that will be positioned by flex layout - -- This position will be overridden unless the child has positioning="absolute" - local parentWillUseFlex = self.parent.positioning ~= "grid" - local childIsRelative = self.positioning ~= "absolute" or not self._explicitlyAbsolute - if parentWillUseFlex and childIsRelative and (props.x or props.y) then - Element._ErrorHandler:warn("Element", "LAY_008", { - element = self.id or "unnamed", - parent = self.parent.id or "unnamed", - properties = (props.x and props.y) and "x, y" or (props.x and "x" or "y"), - }) - end - - _resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 }) - _resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 }) - - self.z = Element._ZIndex.clamp(props.z or self.parent.z or 0) - self.tabIndex = props.tabIndex - end - - if props.textColor then - self.textColor = props.textColor - elseif self.parent.textColor then - self.textColor = self.parent.textColor - else - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.colors and themeToUse.colors.text then - self.textColor = themeToUse.colors.text - else - -- Fallback to black - self.textColor = Element._Color.new(0, 0, 0, 1) - end - end - - -- Handle positioning properties BEFORE adding to parent (so they're available during layout) - -- Warn if CSS positioning properties are supplied but will be ignored. - -- Relative elements honor the offsets as visual deltas (see - -- _applyRelativeOffsets); absolute elements use applyPositioningOffsets. - -- Only flex-participating children (positioning coerced to ABSOLUTE but not - -- explicitly absolute) actually drop the offsets and warrant the warning. - if - (props.top or props.bottom or props.left or props.right) - and not self._explicitlyAbsolute - and self.positioning ~= Element._utils.enums.Positioning.RELATIVE - then - _warnCssPositioningWithoutAbsolute(self, props) - end - - -- Handle top/right/bottom/left positioning with units - if props.top then - _resolveUnit(self, props.top, "top", viewportHeight, _ctx) - end - if props.right then - _resolveUnit(self, props.right, "right", viewportWidth, _ctx) - end - if props.bottom then - _resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx) - end - if props.left then - _resolveUnit(self, props.left, "left", viewportWidth, _ctx) - end - - -- position: relative offsets are applied as visual deltas in - -- LayoutEngine:layoutChildren (after the flex flow places children), so - -- they survive the addChild -> layoutChildren re-entry here. - - props.parent:addChild(self) - end - - if self.positioning == Element._utils.enums.Positioning.FLEX then - -- Validate enum properties - if props.flexDirection then - Element._utils.validateEnum(props.flexDirection, Element._utils.enums.FlexDirection, "flexDirection") - end - if props.flexWrap then - Element._utils.validateEnum(props.flexWrap, Element._utils.enums.FlexWrap, "flexWrap") - end - if props.justifyContent then - Element._utils.validateEnum(props.justifyContent, Element._utils.enums.JustifyContent, "justifyContent") - end - if props.alignItems then - Element._utils.validateEnum(props.alignItems, Element._utils.enums.AlignItems, "alignItems") - end - if props.alignContent then - Element._utils.validateEnum(props.alignContent, Element._utils.enums.AlignContent, "alignContent") - end - if props.justifySelf then - Element._utils.validateEnum(props.justifySelf, Element._utils.enums.JustifySelf, "justifySelf") - end - - -- Warn if grid properties are set with flex positioning - if props.gridRows or props.gridColumns then - Element._ErrorHandler:warn("Element", "LAY_010", { - element = self.id or "unnamed", - positioning = "flex", - properties = "gridRows/gridColumns", - }) - end - - self.flexDirection = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL - self.flexWrap = props.flexWrap or Element._utils.enums.FlexWrap.NOWRAP - self.justifyContent = props.justifyContent or Element._utils.enums.JustifyContent.FLEX_START - self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH - self.alignContent = props.alignContent or Element._utils.enums.AlignContent.STRETCH - self.justifySelf = props.justifySelf or Element._utils.enums.JustifySelf.AUTO - end - - -- Grid container properties - if self.positioning == Element._utils.enums.Positioning.GRID then - -- Warn if flex properties are set with grid positioning - if props.flexDirection or props.flexWrap or props.justifyContent then - Element._ErrorHandler:warn("Element", "LAY_009", { - element = self.id or "unnamed", - positioning = "grid", - properties = "flexDirection/flexWrap/justifyContent", - }) - end - - self.gridRows = props.gridRows - self.gridColumns = props.gridColumns - self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH - - -- Handle columnGap and rowGap - _resolveUnit(self, props.columnGap, "columnGap", self.width, _ctx, { default = 0 }) - _resolveUnit(self, props.rowGap, "rowGap", self.height, _ctx, { default = 0 }) - end - - -- alignSelf is bound by _applyProps (default "auto"). - - -- Update the LayoutEngine with actual layout properties - -- (it was initialized early with defaults for auto-sizing calculations) - self._layoutEngine.positioning = self.positioning - if self.flexDirection then - self._layoutEngine.flexDirection = self.flexDirection - end - if self.flexWrap then - self._layoutEngine.flexWrap = self.flexWrap - end - if self.justifyContent then - self._layoutEngine.justifyContent = self.justifyContent - end - if self.alignItems then - self._layoutEngine.alignItems = self.alignItems - end - if self.alignContent then - self._layoutEngine.alignContent = self.alignContent - end - if self.gap then - self._layoutEngine.gap = self.gap - end - if self.gridRows then - self._layoutEngine.gridRows = self.gridRows - end - if self.gridColumns then - self._layoutEngine.gridColumns = self.gridColumns - end - - if self.columnGap then - self._layoutEngine.columnGap = self.columnGap - end - if self.rowGap then - self._layoutEngine.rowGap = self.rowGap - end - - -- transform is bound by _applyProps; transition is bound by _applyProps (default {}). - -- (Previously set inline here; both are now registry-driven.) -end - ---- Phase 8 (ScrollManager instantiation + immediate-mode scrollbar restore) is ---- owned by the Scrollable behavior (modules/behaviors/Scrollable.lua), attached ---- in _attachBehaviors. There is no longer an Element phase for this — the ---- behavior onAttach hook IS the phase (behavior-mode-unification task 03/08). ---- `overflow` / `overflowX` / `overflowY` are bound onto the element as plain ---- fields by bindThemeAndFields/`_applyProps` so that `Element:addChild`'s ---- scroll-container auto-size guard sees them during declarative-children ---- processing in _finalizeConstruction (which runs before _attachBehaviors); ---- Scrollable.onAttach then overwrites them with the ScrollManager's normalized ---- values, matching the legacy field-exposure order. - ---- Phase 9: immediate-mode registration, dirty flags, debug draw color, ---- declarative children tree, onCreate callback, and constructed flag. -function Element:_finalizeConstruction(props) - -- Register element in z-index tracking. registerElement is a mode-aware - -- no-op outside immediate mode, so no mode check is needed here - -- (behavior-mode-unification task 11). - Element._Context.registerElement(self) - - -- Performance optimization: dirty flags for layout tracking - -- These flags help skip unnecessary layout recalculations - self._dirty = false -- Element properties have changed, needs layout - self._childrenDirty = false -- Children have changed, needs layout - - -- Debug draw: assign a deterministic color for element boundary visualization - -- Uses a hash of the element ID to produce a stable hue, so colors don't flash each frame - local function hashStringToHue(str) - local hash = 5381 - for i = 1, #str do - hash = ((hash * 33) + string.byte(str, i)) % 360 - end - return hash - end - local hue = hashStringToHue(self.id or tostring(self)) - local function hslToRgb(h) - local s, l = 0.9, 0.55 - local c = (1 - math.abs(2 * l - 1)) * s - local x = c * (1 - math.abs((h / 60) % 2 - 1)) - local m = l - c / 2 - local r, g, b - if h < 60 then - r, g, b = c, x, 0 - elseif h < 120 then - r, g, b = x, c, 0 - elseif h < 180 then - r, g, b = 0, c, x - elseif h < 240 then - r, g, b = 0, x, c - elseif h < 300 then - r, g, b = x, 0, c - else - r, g, b = c, 0, x - end - return r + m, g + m, b + m - end - local dr, dg, db = hslToRgb(hue) - self._debugColor = { dr, dg, db } - - -- Process declarative children prop: build child tree from property tables - -- Placed after all self properties are initialized so children can safely access parent state - if props.children then - if type(props.children) ~= "table" then - _warnChildrenInvalid(self, "ELEM_010", "children must be a table array", props.children) - else - for i = 1, #props.children do - local childProps = props.children[i] - if childProps == nil then - _warnChildrenInvalid(self, "ELEM_011", "nil entry in children array, skipping", nil) - elseif type(childProps) ~= "table" then - _warnChildrenInvalid(self, "ELEM_012", "non-table entry in children array, skipping", childProps) - else - local childCopy = {} - for k, v in pairs(childProps) do - childCopy[k] = v - end - childCopy.parent = self - local child = Element.new(childCopy) - - -- Set up state management for declarative children so mutations - -- made in event callbacks persist across frames. Mode-aware via - -- StateManager.isImmediateMode (behavior-mode-unification task 11): - -- this whole block is immediate-mode-only frame bookkeeping. - if Element._StateManager.isImmediateMode() then - if not child.id or child.id == "" then - child.id = Element._StateManager.generateID(childCopy, self) - end - local childState = Element._StateManager.getState(child.id, {}) - Element._StateManager.markStateUsed(child.id) - child:restoreState(childState) - child._stateId = child.id - - -- Restore theme state from event handler state - if child.themeComponent then - local eventState = childState.eventHandler or {} - if child.disabled or eventState.disabled then - child._themeState = "disabled" - elseif child.active or eventState.active then - child._themeState = "active" - elseif eventState._pressed and next(eventState._pressed) then - child._themeState = "pressed" - elseif eventState._hovered then - child._themeState = "hover" - else - child._themeState = "normal" - end - end - - -- Add to current frame elements for saveState tracking - if Element._Context._currentFrameElements then - table.insert(Element._Context._currentFrameElements, child) - end - end - end - end - end - end - - -- Fire onCreate callback if provided - if self.onCreate then - if self.onCreateDeferred then - local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] - if FlexLove and FlexLove.deferCallback then - FlexLove.deferCallback(function() - self.onCreate(self, props) - end) - else - self.onCreate(self, props) - end - else - self.onCreate(self, props) - end - end - - -- Mark element as fully constructed. - -- NOTE: no longer gates an __newindex dimension warning (removed — see comment - -- at top of file). Retained lazily in case future write-interception is added. - self._constructed = true -end - ---- Retrieve the element's screen-space rectangle for collision detection and positioning calculations ---- Use this for custom layout logic, tooltips, or detecting overlaps between elements ----@return { x:number, y:number, width:number, height:number } -function Element:getBounds() - return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } -end - ---- Test if a screen coordinate falls within the element's clickable area ---- Use this for custom hit detection or determining which element the mouse is over ---- @param x number ---- @param y number ---- @return boolean -function Element:contains(x, y) - local bounds = self:getBounds() - return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y -end - ---- Get the element's total width including padding for layout calculations ---- Use this when you need the full visual width rather than just content width ----@return number -function Element:getBorderBoxWidth() - return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) -end - ---- Get the element's total height including padding for layout calculations ---- Use this when you need the full visual height rather than just content height ----@return number -function Element:getBorderBoxHeight() - return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) -end - ---- Get computed box dimensions (content area position and size) ---- Returns the position and size of the content area (inside padding) ----@return {x: number, y: number, width: number, height: number} -function Element:getComputedBox() - return { - x = self.x + self.padding.left, - y = self.y + self.padding.top, - width = self.width, - height = self.height, - } -end - ---- Mark this element and its ancestors as dirty, requiring layout recalculation ---- Call this when element properties change that affect layout -function Element:invalidateLayout() - self._dirty = true - - -- Invalidate dimension caches - self._borderBoxWidthCache = nil - self._borderBoxHeightCache = nil - - -- Mark parent as having dirty children - if self.parent then - self.parent._childrenDirty = true - -- Propagate up the tree (parents need to know their descendants changed) - local ancestor = self.parent - while ancestor do - ancestor._childrenDirty = true - ancestor = ancestor.parent - end - end -end - --- Scroll / scrollbar methods (_syncScrollManagerState, _detectOverflow, setScrollPosition, --- _calculateScrollbarDimensions, _getScrollbarAtPosition, _handleScrollbarPress/Drag/Release, --- _handleWheelScroll, getScrollPosition, getMaxScroll, getScrollPercentage, hasOverflow, --- getContentSize, scrollBy, scrollToTop) are bound to ScrollManager in Element.init. ScrollManager --- owns all scrollbar interaction logic; Element retains only 1-line delegates (see ScrollManager.lua). - ---- Mark a method for deferred retry during the update phase. ---- Methods that depend on layout calculations (e.g., scroll, sizing) ---- can defer themselves when preconditions aren't met. They'll be ---- retried automatically each frame in update() until they succeed. ----@param methodName string The method name to retry ----@param ... any? Arguments to forward on retry -function Element:_deferMethod(methodName, ...) - if type(self[methodName]) ~= "function" then - Element._ErrorHandler:warn("Element", "CORE_005", { - element = self.id, - method = tostring(methodName), - }) - return - end - - if #self._deferredMethods >= MAX_DEFERRED_METHODS then - Element._ErrorHandler:warn("Element", "CORE_004", { - element = self.id, - method = tostring(methodName), - retryCount = MAX_DEFERRED_METHODS, - }) - return - end - - local argc = select("#", ...) - local args = {} - for i = 1, argc do - local val = select(i, ...) - args[i] = val == nil and _DEFERRED_NIL or val - end - table.insert(self._deferredMethods, { - methodName = methodName, - args = args, - argc = argc, - retryCount = 0, - }) -end - --- Deferred image loading is owned by the Imageable behavior --- (modules/behaviors/Imageable.lua). Imageable.onAttach installs an instance --- closure on `element._loadImage` and defers it via _deferMethod; the deferred- --- method dispatcher (which resolves `self[methodName]`) invokes that closure. --- Element no longer owns the load logic itself and has zero image-branch logic. --- (behavior-mode-unification task 07) - --- scrollToBottom / scrollToLeft / scrollToRight are bound to ScrollManager in Element.init. - ---- Get the current state's scaled content padding ---- Returns the contentPadding for the current theme state, scaled to the element's size ----@return table|nil -- {left, top, right, bottom} or nil if no contentPadding -function Element:getScaledContentPadding() - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - return self._themeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) -end - ---- Get draw-time content offset from state-specific theme padding changes ----@return number offsetX, number offsetY -function Element:getContentStateOffset() - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - - local currentPadding = self:getScaledContentPadding() - local basePadding = self._themeManager:_getScaledContentPaddingForState("normal", borderBoxWidth, borderBoxHeight) - - if not currentPadding or not basePadding then - return 0, 0 - end - - local offsetX = currentPadding.left - basePadding.left - local offsetY = currentPadding.top - basePadding.top - - if math.abs(offsetX) < 0.001 then - offsetX = 0 - end - if math.abs(offsetY) < 0.001 then - offsetY = 0 - end - - return offsetX, offsetY -end - ---- Get or create blur instance for this element ----@return table? -- Blur instance or nil if no blur configured -function Element:getBlurInstance() - -- Determine quality from contentBlur or backdropBlur - local quality = 5 -- Default quality - if self.contentBlur and self.contentBlur.quality then - quality = self.contentBlur.quality - elseif self.backdropBlur and self.backdropBlur.quality then - quality = self.backdropBlur.quality - end - - -- Create blur instance if needed - if not self._blurInstance or self._blurInstance.quality ~= quality then - self._blurInstance = Element._Blur.new({ quality = quality }) - end - - return self._blurInstance -end - ---- Get available content width for children (accounting for 9-patch content padding) ---- This is the width that children should use when calculating percentage widths ----@return number -function Element:getAvailableContentWidth() - local availableWidth = self.width - - local scaledContentPadding = self:getScaledContentPadding() - if scaledContentPadding then - -- Check if the element is using the scaled 9-patch contentPadding as its padding - -- Allow small floating point differences (within 0.1 pixels) - local usingContentPaddingAsPadding = ( - math.abs(self.padding.left - scaledContentPadding.left) < 0.1 - and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 - ) - - if not usingContentPaddingAsPadding then - -- Element has explicit padding different from contentPadding - -- Subtract scaled contentPadding to get the area children should use - availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right - end - end - - return math.max(0, availableWidth) -end - ---- Get available content height for children (accounting for 9-patch content padding) ---- This is the height that children should use when calculating percentage heights ----@return number -function Element:getAvailableContentHeight() - local availableHeight = self.height - - local scaledContentPadding = self:getScaledContentPadding() - if scaledContentPadding then - -- Check if the element is using the scaled 9-patch contentPadding as its padding - -- Allow small floating point differences (within 0.1 pixels) - local usingContentPaddingAsPadding = ( - math.abs(self.padding.top - scaledContentPadding.top) < 0.1 - and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 - ) - - if not usingContentPaddingAsPadding then - -- Element has explicit padding different from contentPadding - -- Subtract scaled contentPadding to get the area children should use - availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom - end - end - - return math.max(0, availableHeight) -end - -function Element:openSelect() - Element._Select.openSelect(self) -end - -function Element:closeSelect() - Element._Select.closeSelect(self) -end - -function Element:toggleSelect() - Element._Select.toggleSelect(self) -end - ----@return boolean -function Element:isSelectOpen() - return Element._Select.isSelectOpen(self) -end - ----@return any -function Element:getSelectValue() - return Element._Select.getSelectValue(self) -end - ----@return string? -function Element:getSelectLabel() - return Element._Select.getSelectLabel(self) -end - ----@return boolean -function Element:isSelectedSelectOption() - return Element._Select.isSelectedOption(self) -end - ----@param value any ----@param optionElement Element? -function Element:setSelectValue(value, optionElement) - Element._Select.setSelectValue(self, value, optionElement) -end - -function Element:_handleSelectRelease() - Element._Select.handleRelease(self) -end - ---- Dynamically insert a child element into the hierarchy for runtime UI construction ---- Use this to build interfaces procedurally or add elements based on application state ----@param child Element -function Element:addChild(child) - if self._managedSelectFrame and child.selectOption and self._managedSelectOwner then - child._selectParentHint = self._managedSelectOwner - end - - child.parent = self - - -- Re-evaluate positioning now that we have a parent - -- If child was created without explicit positioning, inherit from parent - if child._originalPositioning == nil then - -- No explicit positioning was set during construction - if - self.positioning == Element._utils.enums.Positioning.FLEX - or self.positioning == Element._utils.enums.Positioning.GRID - then - child.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid - child._explicitlyAbsolute = false -- Participate in parent's layout - else - child.positioning = Element._utils.enums.Positioning.RELATIVE - child._explicitlyAbsolute = false -- Default for relative/absolute containers - end - end - -- If child._originalPositioning is set, it means explicit positioning was provided - -- and _explicitlyAbsolute was already set correctly during construction - - table.insert(self.children, child) - Element._Select.registerWithSelectParent(child) - - -- Mark parent as having dirty children to trigger layout recalculation - self._childrenDirty = true - - -- Only recalculate auto-sizing if the child participates in layout - -- (CSS: absolutely positioned children don't affect parent auto-sizing) - if not child._explicitlyAbsolute then - local sizeChanged = false - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - local isScrollContainer = overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - - if self.autosizing.height and not isScrollContainer then - local oldHeight = self.height - local contentHeight = self:calculateAutoHeight() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom - self.height = contentHeight - if oldHeight ~= self.height then - sizeChanged = true - end - end - if self.autosizing.width and not isScrollContainer then - local oldWidth = self.width - local contentWidth = self:calculateAutoWidth() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right - self.width = contentWidth - if oldWidth ~= self.width then - sizeChanged = true - end - end - - -- Propagate size change up the tree - if sizeChanged and self.parent and (self.parent.autosizing.width or self.parent.autosizing.height) then - -- Trigger parent to recalculate its size by re-adding this child's contribution - -- This ensures grandparents are notified of size changes - if self.parent.autosizing.height then - local contentHeight = self.parent:calculateAutoHeight() - self.parent._borderBoxHeight = contentHeight + self.parent.padding.top + self.parent.padding.bottom - self.parent.height = contentHeight - end - if self.parent.autosizing.width then - local contentWidth = self.parent:calculateAutoWidth() - self.parent._borderBoxWidth = contentWidth + self.parent.padding.left + self.parent.padding.right - self.parent.width = contentWidth - end - end - end - - -- Layout is deferred to FlexLove.endFrame in immediate mode (all elements - -- for the frame must exist before layout). shouldLayout() encapsulates the - -- mode check (behavior-mode-unification task 11). - if Element._StateManager.shouldLayout() then - self:layoutChildren() - end - - if - self._selectState - and self._selectState.selectFrame - and child.selectOption - and child ~= self._selectState.selectFrame - then - Element._Select.attachOptionToManagedFrame(child) - end -end - ---- Remove a child element from the hierarchy to dynamically update UIs ---- Use this to delete elements when they're no longer needed or respond to user actions ----@param child Element -function Element:removeChild(child) - for i, c in ipairs(self.children) do - if c == child then - Element._Select.handleChildRemoved(self, child) - Element._Select.unregisterFromSelectParent(child) - table.remove(self.children, i) - child.parent = nil - - -- Recalculate auto-sizing if needed - if self.autosizing.width or self.autosizing.height then - if self.autosizing.width then - local contentWidth = self:calculateAutoWidth() - self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right - self.width = contentWidth - end - if self.autosizing.height then - local contentHeight = self:calculateAutoHeight() - self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom - self.height = contentHeight - end - end - - -- Re-layout children after removal (deferred in immediate mode). - if Element._StateManager.shouldLayout() then - self:layoutChildren() - end - - break - end - end -end - ---- Reparent this element to a new parent, properly detaching from the current location ---- and inserting into the new parent's children hierarchy with correct layout and alignment. ---- If newParent is nil, the element becomes a top-level element. ---- Works whether the element was originally created with or without a parent. ----@param newParent Element? -function Element:setParent(newParent) - local expectedManagedSelectParent = nil - if self._managedSelectFrame and self._managedSelectOwner then - expectedManagedSelectParent = self._managedSelectOwner - if self._managedSelectOwner._selectState and self._managedSelectOwner._selectState.selectAnchor then - expectedManagedSelectParent = self._managedSelectOwner._selectState.selectAnchor - end - end - - if self._managedSelectFrame and self._managedSelectOwner and newParent ~= expectedManagedSelectParent then - Element._Select.warnSelectFrame(self._managedSelectOwner, "ELEM_009", { - element = self._managedSelectOwner.id, - frame = self.id, - expectedParent = expectedManagedSelectParent and expectedManagedSelectParent.id or nil, - actualParent = newParent and newParent.id or nil, - }) - end - - if self.parent == newParent then - return -- Already at this parent, no-op - end - - -- Remove from current location - if self.parent then - -- removeChild sets child.parent = nil and recalculates parent layout - self.parent:removeChild(self) - else - -- Remove from topElements (element was created without a parent) - for i, elem in ipairs(Element._Context.topElements) do - if elem == self then - table.remove(Element._Context.topElements, i) - break - end - end - self.parent = nil - end - - if newParent then - -- addChild handles: setting self.parent, re-evaluating positioning, - -- inserting into children, marking dirty, auto-sizing, and layoutChildren - newParent:addChild(self) - else - -- Become a top-level element - self.parent = nil - self.x = self.x or 0 - self.y = self.y or 0 - self.z = Element._ZIndex.clamp(self.z or 0) - table.insert(Element._Context.topElements, self) - end -end - ---- Delete all child elements at once for resetting containers or clearing lists ---- Use this to efficiently empty containers when rebuilding UI from scratch -function Element:clearChildren() - -- Clear parent references for all children - for _, child in ipairs(self.children) do - Element._Select.unregisterFromSelectParent(child) - child.parent = nil - end - - -- Clear the children table - self.children = {} - - -- Recalculate auto-sizing if needed - if self.autosizing.width or self.autosizing.height then - if self.autosizing.width then - local contentWidth = self:calculateAutoWidth() - self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right - self.width = contentWidth - end - if self.autosizing.height then - local contentHeight = self:calculateAutoHeight() - self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom - self.height = contentHeight - end - end - - -- Re-layout (though there are no children now; deferred in immediate mode). - if Element._StateManager.shouldLayout() then - self:layoutChildren() - end -end - ---- Get the number of children this element has ----@return number -function Element:getChildCount() - return #self.children -end - ---- Apply positioning offsets (top, right, bottom, left) to an element --- @param element The element to apply offsets to -function Element:applyPositioningOffsets(element) - -- Delegate to LayoutEngine - self._layoutEngine:applyPositioningOffsets(element) -end - -function Element:layoutChildren() - -- Check performance warnings (only on root elements to avoid spam) - if not self.parent then - self:_checkPerformanceWarnings() - end - - -- Catch stale bare dimension writes that bypassed setProperty (e.g. - -- `element.width = "42%"` stores a raw string). Lua __newindex cannot intercept - -- these at write time (the keys exist post-construction), so we validate lazily - -- here, once per element per property, only when a reflow is already pending. - if self._dirty then - self:_checkDimensionTypes() - end - - -- Delegate layout to LayoutEngine - self._layoutEngine:layoutChildren() -end - ---- Warn once per stale dimension property that holds a non-number value, which ---- indicates a bare write (e.g. `element.width = "42%"`) bypassed setProperty. ---- Bare dimension writes neither resolve unit strings nor invalidate layout; ---- the element renders with the wrong size until :setProperty() is used. -function Element:_checkDimensionTypes() - if not self._dimWarned then - self._dimWarned = {} - end - for _, prop in ipairs({ "width", "height", "x", "y" }) do - local v = self[prop] - if v ~= nil and type(v) ~= "number" then - if not self._dimWarned[prop] then - self._dimWarned[prop] = true - Element._ErrorHandler:warn("Element", "ELM_001", { - property = prop, - message = string.format( - 'element.%s holds a non-number value (%s); a bare write bypassed setProperty and was not resolved to pixels. Use element:setProperty("%s", value) instead.', - prop, - type(v), - prop - ), - }) - end - end - end -end - ---- Warn about percentage sizing with auto-sizing parent ----@param child Element ----@param axis string "width" or "height" -function Element:_warnIfPercentageWithAutoSizing(child, axis) - if self._managedSelectFrame then - return - end - Element._ErrorHandler:warn("LayoutEngine", "LAY_004", { - child = child.id or "unnamed", - issue = "percentage " .. axis .. " with parent auto-sizing", - }) -end - ---- Whether element needs cross-axis percentage dimension syncing ---- Managed select frames sync percentage children with container dimensions ----@return boolean -function Element:_shouldSyncPercentageDimensions() - return self._managedSelectFrame == true -end - ---- Adjust cross-axis percentage width for managed select minimum ----@param child Element ----@param newBorderBoxWidth number ----@return number -function Element:_adjustCrossAxisPercentageWidth(child, newBorderBoxWidth) - if self._managedSelectFrame and self.autosizing and self.autosizing.width then - local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right - return math.max(newBorderBoxWidth, intrinsicBorderBoxWidth) - end - return newBorderBoxWidth -end - ---- Layout-path delegate: adjust child border-box width for a managed-select frame. ---- Owned by Select; routed through here so the layout path stays free of dropdown details. ----@param child Element ----@param childBorderBoxWidth number ----@return number -function Element:_adjustAutoWidthChildBorderBoxForManagedSelect(child, childBorderBoxWidth) - return Element._Select.adjustAutoWidthChild(self, child, childBorderBoxWidth) -end - ---- Destroy element and its children -function Element:destroy() - -- Remove from global elements list - for i, win in ipairs(Element._Context.topElements) do - if win == self then - table.remove(Element._Context.topElements, i) - break - end - end - - if self.parent then - for i, child in ipairs(self.parent.children) do - if child == self then - Element._Select.unregisterFromSelectParent(self) - table.remove(self.parent.children, i) - break - end - end - self.parent = nil - end - - -- Destroy all children - for _, child in ipairs(self.children) do - child:destroy() - end - - -- Clear children table - self.children = {} - - -- Clear parent reference - if self.parent then - self.parent = nil - end - - -- Clear animation reference - self.animation = nil - - -- Clear onEvent to prevent closure leaks - self.onEvent = nil - - -- Clear touch callbacks to prevent closure leaks - self.onTouchEvent = nil - self.onGesture = nil - - Element._Select.cleanupDestroy(self) -end - ---- Retry deferred methods queued via `_deferMethod` during this frame. Each ---- pending entry is invoked through pcall; failures are reported to the ---- ErrorHandler instead of aborting the frame, and entries that re-defer are ---- retried next frame with an incremented retry count up to MAX_DEFER_RETRIES. ---- Extracted from the tail of Element:update so update stays a thin ---- behavior-dispatch orchestrator (behavior-mode-unification task 09). -function Element:_processDeferredMethods() - if #self._deferredMethods == 0 then - return - end - local pending = self._deferredMethods - self._deferredMethods = {} - for _, entry in ipairs(pending) do - if entry.retryCount >= MAX_DEFER_RETRIES then - Element._ErrorHandler:warn("Element", "CORE_004", { - element = self.id, - method = tostring(entry.methodName), - retryCount = entry.retryCount, - }) - else - local beforeCount = #self._deferredMethods - local callArgs = {} - for j = 1, entry.argc do - local val = entry.args[j] - if val == _DEFERRED_NIL then - callArgs[j] = nil - else - callArgs[j] = val - end - end - local success, err = pcall(function() - self[entry.methodName](self, unpack(callArgs, 1, entry.argc)) - end) - if not success then - Element._ErrorHandler:warn("Element", "CORE_002", { - element = self.id, - method = tostring(entry.methodName), - error = tostring(err), - }) - end - -- Propagate retry count to any new deferred entry for the same method - for i = beforeCount + 1, #self._deferredMethods do - if self._deferredMethods[i].methodName == entry.methodName then - self._deferredMethods[i].retryCount = entry.retryCount + 1 - end - end - end - end -end - ---- Draw element and its children -function Element:draw(backdropCanvas) - -- Early exit if element is display:none or invisible (optimization) - if self.display == false or self.opacity <= 0 or self.visibility == "hidden" then - return - end - - -- Background behaviors (drawLayer ~= "overlay") render BEFORE children in - -- registry order: Themed (core Renderer:draw), Clickable (pressed overlay), - -- ... Overlay behaviors (Scrollable scrollbars) render AFTER children below. - local drawCtx = { backdropCanvas = backdropCanvas } - local behaviors = self.behaviors - for i = 1, #behaviors do - local b = behaviors[i] - if b.drawLayer ~= "overlay" then - b.onDraw(self, drawCtx) - end - end - - -- Core child hierarchy rendering (clipping, sorting, scroll offset, blur). - -- Stays in Element: it is structural, not a per-capability behavior. - self:_drawChildren(backdropCanvas) - - -- Overlay behaviors (drawLayer == "overlay") render AFTER children so they - -- paint on top, e.g. Scrollable's scrollbars (behavior-mode-unification 09). - for i = 1, #behaviors do - local b = behaviors[i] - if b.drawLayer == "overlay" then - b.onDraw(self, drawCtx) - end - end -end - ---- Core child-drawing pipeline extracted from Element:draw so the draw entry ---- point stays a thin behavior-dispatch orchestrator (task 09). Owns z-sort, ---- rounded-corner/overflow clipping (stencil > scissor), scroll/content offset, ---- optional content-blur application, and recursive child:draw. Not a behavior ---- — this is structural hierarchy rendering shared by every element. -function Element:_drawChildren(backdropCanvas) - local sortedChildren = {} - for _, child in ipairs(self.children) do - table.insert(sortedChildren, child) - end - if #sortedChildren == 0 then - return - end - table.sort(sortedChildren, function(a, b) - return a.z < b.z - end) - - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - - -- Check if we need to clip children to rounded corners - local hasRoundedCorners = false - if self.cornerRadius then - if type(self.cornerRadius) == "number" then - hasRoundedCorners = self.cornerRadius > 0 - else - hasRoundedCorners = self.cornerRadius.topLeft > 0 - or self.cornerRadius.topRight > 0 - or self.cornerRadius.bottomLeft > 0 - or self.cornerRadius.bottomRight > 0 - end - end - - -- Render the (possibly clipped + offset) child layer, applying content blur - -- when configured. The inner closure performs clipping/offset/draw; blur - -- wraps it in a region pass when a blur instance is available. - local function renderChildLayer() - local contentOffsetX, contentOffsetY = self:getContentStateOffset() - - -- Determine overflow behavior per axis (matches HTML/CSS behavior) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") - and (overflowX ~= nil or overflowY ~= nil) - - -- Apply scroll/content offset after clipping is set - local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) - local hasContentOffset = contentOffsetX ~= 0 or contentOffsetY ~= 0 - local hasOffset = hasScrollOffset or hasContentOffset - - -- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none - local clipMode = "none" - local prevSx, prevSy, prevSw, prevSh - if hasRoundedCorners then - local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - local stencilFunc = - Element._RoundedRect.stencilFunction(self.x, self.y, roundedBoxWidth, roundedBoxHeight, self.cornerRadius) - local currentCanvas = love.graphics.getCanvas() - love.graphics.setCanvas() - love.graphics.stencil(stencilFunc, "replace", 1) - love.graphics.setCanvas(currentCanvas) - love.graphics.setStencilTest("greater", 0) - clipMode = "stencil" - elseif needsOverflowClipping then - -- Intersect with (and later restore) any ancestor scissor: replacing - -- it let a nested scroller widen its parent's clip, and the bare - -- setScissor() restore then cleared it entirely - scrolled page - -- content drew straight over the pinned header. - -- transformPoint: scissor rects live in window coordinates and ignore - -- the scroll translate an ancestor scroller has pushed, so a nested - -- scroller's clip sat at its untranslated layout position and cut its - -- own rows away once the page scrolled. - prevSx, prevSy, prevSw, prevSh = love.graphics.getScissor() - local scx, scy = love.graphics.transformPoint( - self.x + self.padding.left, self.y + self.padding.top) - love.graphics.intersectScissor(scx, scy, self.width, self.height) - clipMode = "scissor" - end - - if hasOffset then - love.graphics.push() - love.graphics.translate( - (hasScrollOffset and -self._scrollX or 0) + contentOffsetX, - (hasScrollOffset and -self._scrollY or 0) + contentOffsetY - ) - end - - for _, child in ipairs(sortedChildren) do - child:draw(backdropCanvas) - end - - if hasOffset then - love.graphics.pop() - end - - -- Restore clipping state - if clipMode == "stencil" then - love.graphics.setStencilTest() - elseif clipMode == "scissor" then - if prevSx then - love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh) - else - love.graphics.setScissor() - end - end - end - - -- Apply content blur if configured - if self.contentBlur and self.contentBlur.radius > 0 then - local blurInstance = self:getBlurInstance() - if blurInstance then - Element._Blur.applyToRegion( - blurInstance, - self.contentBlur.radius, - self.x, - self.y, - borderBoxWidth, - borderBoxHeight, - renderChildLayer - ) - else - renderChildLayer() - end - else - renderChildLayer() - end -end - ---- Update element (propagate to children) ----@param dt number -function Element:update(dt) - if self.display == false then - return - end - if not self.parent then - self:_trackActiveAnimations() - end - for _, child in ipairs(self.children) do - child:update(dt) - end - -- Advance direct-assignment animations before the loop so geometry is current - -- for hit-testing; no-ops when the Animated behavior is already attached. - Element._dispatchAnimatedUpdate(self, dt) - for _, b in ipairs(self.behaviors) do - b.onUpdate(self, dt) - end - self:_processDeferredMethods() -end - ---- Handle a touch event directly (for external touch routing) ---- Invokes both onEvent and onTouchEvent callbacks if set ----@param touchEvent InputEvent The touch event to handle -function Element:handleTouchEvent(touchEvent) - if not self.touchEnabled or self.disabled then - return - end - if self._eventHandler then - self._eventHandler:_invokeCallback(self, touchEvent) - self._eventHandler:_invokeTouchCallback(self, touchEvent) - end -end - ---- Handle a gesture event (from GestureRecognizer or external routing) ----@param gesture table The gesture data (type, position, velocity, etc.) -function Element:handleGesture(gesture) - if not self.touchEnabled or self.disabled then - return - end - if self._eventHandler then - self._eventHandler:_invokeGestureCallback(self, gesture) - end -end - ---- Get active touches currently tracked on this element ----@return table Active touches keyed by touch ID -function Element:getTouches() - if self._eventHandler then - return self._eventHandler:getActiveTouches() - end - return {} -end - ----@param newViewportWidth number ----@param newViewportHeight number -function Element:recalculateUnits(newViewportWidth, newViewportHeight) - self._layoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) -end - ---- Resize element and its children based on game window size change ----@param newGameWidth number ----@param newGameHeight number -function Element:resize(newGameWidth, newGameHeight) - self:recalculateUnits(newGameWidth, newGameHeight) - self:_refreshSizeConstraints(newGameWidth, newGameHeight) - - -- For non-auto-sized elements with viewport/percentage units, update content dimensions from border-box - if not self.autosizing.width and self._borderBoxWidth and self.units.width.unit ~= "px" then - self._borderBoxWidth = Element._utils.clamp(self._borderBoxWidth, self.minWidth, self.maxWidth) - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - end - if not self.autosizing.height and self._borderBoxHeight and self.units.height.unit ~= "px" then - self._borderBoxHeight = Element._utils.clamp(self._borderBoxHeight, self.minHeight, self.maxHeight) - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - end - - -- Update children - for _, child in ipairs(self.children) do - child:resize(newGameWidth, newGameHeight) - end - - -- Recalculate auto-sized dimensions after children are resized - if self.autosizing.width then - local contentWidth = self:calculateAutoWidth() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxWidth = - Element._utils.clamp(contentWidth + self.padding.left + self.padding.right, self.minWidth, self.maxWidth) - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - -- CONTENT-LEVEL CLAMP: CSS min-width/max-width also bound the content width. - -- Subtracting padding from the clamped border-box can drop the content width - -- below minWidth (e.g. minWidth=200, horizontal padding=100 => content=100), - -- so re-clamp the content dimension with the shared size-clamping utility. - self.width = Element._utils.clampSize(self.width, self.minWidth, self.maxWidth) - end - if self.autosizing.height then - local contentHeight = self:calculateAutoHeight() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxHeight = - Element._utils.clamp(contentHeight + self.padding.top + self.padding.bottom, self.minHeight, self.maxHeight) - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - -- CONTENT-LEVEL CLAMP: CSS min-height/max-height also bound the content height. - -- Subtracting padding from the clamped border-box can drop the content height - -- below minHeight (e.g. minHeight=200, vertical padding=100 => content=100), - -- so re-clamp the content dimension with the shared size-clamping utility. - self.height = Element._utils.clampSize(self.height, self.minHeight, self.maxHeight) - end - - -- Re-resolve textSize if it uses viewport-relative units after dimensions are finalized - - self:layoutChildren() - self.prevGameSize.width = newGameWidth - self.prevGameSize.height = newGameHeight -end - -function Element:_refreshSizeConstraints(newViewportWidth, newViewportHeight) - local scaleX, scaleY = Element._Context.getScaleFactors() - local ctx = { vw = newViewportWidth, vh = newViewportHeight, sx = scaleX, sy = scaleY } - local parentW = self.parent and self.parent.width or newViewportWidth - local parentH = self.parent and self.parent.height or newViewportHeight - _refreshUnit(self, "minWidth", parentW, ctx, "x") - _refreshUnit(self, "maxWidth", parentW, ctx, "x") - _refreshUnit(self, "minHeight", parentH, ctx, "y") - _refreshUnit(self, "maxHeight", parentH, ctx, "y") -end - ---- Calculate text width for button ----@return number -function Element:calculateTextWidth() - if self.text == nil then - return 0 - end - - local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) - local width = font:getWidth(self.text) - return Element._utils.applyContentMultiplier(width, self.contentAutoSizingMultiplier, "width") -end - ----@return number -function Element:calculateTextHeight() - if self.text == nil then - return 0 - end - - local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) - local height = font:getHeight() - - if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then - local availableWidth = self.width - - if (not availableWidth or availableWidth <= 0) and self.parent then - availableWidth = self.parent.width - end - - if availableWidth and availableWidth > 0 then - local _, wrappedLines = font:getWrap(self.text, availableWidth) - height = height * #wrappedLines - end - end - - return Element._utils.applyContentMultiplier(height, self.contentAutoSizingMultiplier, "height") -end - -function Element:calculateAutoWidth() - local contentWidth = self._layoutEngine:calculateAutoWidth() - if self._managedSelectMinimumBorderBoxWidth then - local minimumContentWidth = - math.max(0, self._managedSelectMinimumBorderBoxWidth - self.padding.left - self.padding.right) - contentWidth = math.max(contentWidth, minimumContentWidth) - end - return contentWidth -end - ---- Calculate auto height based on children -function Element:calculateAutoHeight() - return self._layoutEngine:calculateAutoHeight() -end - ----@param newText string ----@param autoresize boolean? --default: false -function Element:updateText(newText, autoresize) - self.text = newText or self.text - if autoresize then - self.width = self:calculateTextWidth() - self.height = self:calculateTextHeight() - end -end - ----@param newOpacity number -function Element:updateOpacity(newOpacity) - self.opacity = newOpacity - for _, child in ipairs(self.children) do - child:updateOpacity(newOpacity) - end -end - ---- same as calling updateOpacity(0) -function Element:hide() - self:updateOpacity(0) -end - ---- same as calling updateOpacity(1) -function Element:show() - self:updateOpacity(1) -end - --- ==================== --- Input Handling - Text Editing (behavior-delegated, task 04) --- ==================== --- All text-editor operations are dispatched through the TextEditable behavior --- (modules/behaviors/TextEditable.lua). Element retains only thin 1-line --- forwarders for backward-compat with external callers (EventHandler, --- KeyboardNavigation, Renderer, game UI). The behavior owns the TextEditor --- subsystem (onAttach creates it, onUpdate drives cursor blink, saveState / --- restoreState persist it) AND implements the delegate bodies (text sync, --- auto-grow, nil-guarding element._textEditor) — so Element carries zero --- text-editor nil-guard branches and zero text-editor logic. --- --- `_wrapLine` / `_getFont` remain here: they are RENDERER forwarders (not --- TextEditor delegates), and the TextEditor has its own implementations. --- `updateText` (above) is a plain-label text setter, not a TextEditor delegate. --- ==================== - ---- Set cursor position (delegates to TextEditable behavior) ----@param position number -- Character index (0-based) -function Element:setCursorPosition(position) - return Element._TextEditable.setCursorPosition(self, position) -end - ---- Get cursor position (delegates to TextEditable behavior) ----@return number -- Character index (0-based) -function Element:getCursorPosition() - return Element._TextEditable.getCursorPosition(self) -end - ---- Move cursor by delta characters (delegates to TextEditable behavior) ----@param delta number -- Number of characters to move (positive or negative) -function Element:moveCursorBy(delta) - return Element._TextEditable.moveCursorBy(self, delta) -end - ---- Move cursor to start of text (delegates to TextEditable behavior) -function Element:moveCursorToStart() - return Element._TextEditable.moveCursorToStart(self) -end - ---- Move cursor to end of text (delegates to TextEditable behavior) -function Element:moveCursorToEnd() - return Element._TextEditable.moveCursorToEnd(self) -end - ---- Move cursor to start of current line (delegates to TextEditable behavior) -function Element:moveCursorToLineStart() - return Element._TextEditable.moveCursorToLineStart(self) -end - ---- Move cursor to end of current line (delegates to TextEditable behavior) -function Element:moveCursorToLineEnd() - return Element._TextEditable.moveCursorToLineEnd(self) -end - ---- Move cursor to start of previous word (delegates to TextEditable behavior) -function Element:moveCursorToPreviousWord() - return Element._TextEditable.moveCursorToPreviousWord(self) -end - ---- Move cursor to start of next word (delegates to TextEditable behavior) -function Element:moveCursorToNextWord() - return Element._TextEditable.moveCursorToNextWord(self) -end - ---- Set selection range (delegates to TextEditable behavior) ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function Element:setSelection(startPos, endPos) - return Element._TextEditable.setSelection(self, startPos, endPos) -end - ---- Get selection range (delegates to TextEditable behavior) ----@return number?, number? -- Start and end positions, or nil if no selection -function Element:getSelection() - return Element._TextEditable.getSelection(self) -end - ---- Check if there is an active selection (delegates to TextEditable behavior) ----@return boolean -function Element:hasSelection() - return Element._TextEditable.hasSelection(self) -end - ---- Clear selection (delegates to TextEditable behavior) -function Element:clearSelection() - return Element._TextEditable.clearSelection(self) -end - ---- Select all text (delegates to TextEditable behavior) -function Element:selectAll() - return Element._TextEditable.selectAll(self) -end - ---- Get selected text (delegates to TextEditable behavior) ----@return string? -- Selected text or nil if no selection -function Element:getSelectedText() - return Element._TextEditable.getSelectedText(self) -end - ---- Delete selected text (delegates to TextEditable behavior, which owns text sync + auto-grow) ----@return boolean -- True if text was deleted -function Element:deleteSelection() - return Element._TextEditable.deleteSelection(self) -end - ---- Give this element keyboard focus to enable text input or keyboard navigation ---- Use this to automatically focus text fields when showing forms or dialogs -function Element:focus() - return Element._TextEditable.focus(self) -end - ---- Remove keyboard focus to stop capturing input events ---- Use this when closing popups or switching focus to other elements -function Element:blur() - return Element._TextEditable.blur(self) -end - ---- Query focus state to conditionally render focus indicators or handle keyboard input ---- Use this to style focused elements or determine which element receives keyboard events ----@return boolean -function Element:isFocused() - return Element._TextEditable.isFocused(self) -end - ---- Retrieve the element's current text content for processing or validation ---- Use this to read user input from text fields or get display text ----@return string -function Element:getText() - return Element._TextEditable.getText(self) -end - ---- Update the element's text content programmatically for dynamic labels or resetting inputs ---- Use this to change text without user input, like clearing fields or updating status messages ----@param text string -function Element:setText(text) - return Element._TextEditable.setText(self, text) -end - ---- Programmatically insert text at any position for autocomplete or text manipulation ---- Use this to implement suggestions, templates, or text snippets ----@param text string -- Text to insert ----@param position number? -- Position to insert at (default: cursor position) -function Element:insertText(text, position) - return Element._TextEditable.insertText(self, text, position) -end - ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function Element:deleteText(startPos, endPos) - return Element._TextEditable.deleteText(self, startPos, endPos) -end - ---- Replace text in range ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) ----@param newText string -- Replacement text -function Element:replaceText(startPos, endPos, newText) - return Element._TextEditable.replaceText(self, startPos, endPos, newText) -end - ---- Wrap a single line of text ----@param line string -- Line to wrap ----@param maxWidth number -- Maximum width in pixels ----@return table -- Array of wrapped line parts -function Element:_wrapLine(line, maxWidth) - return self._renderer:wrapLine(self, line, maxWidth) -end - ----@return love.Font -function Element:_getFont() - return self._renderer:getFont(self) -end - --- ==================== --- Input Handling - Mouse Selection --- ==================== - ---- Handle mouse click on text (set cursor position or start selection) ---- Delegates to the TextEditable behavior, which owns drag tracking. ----@param mouseX number -- Mouse X coordinate ----@param mouseY number -- Mouse Y coordinate ----@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple) -function Element:_handleTextClick(mouseX, mouseY, clickCount) - return Element._TextEditable._handleTextClick(self, mouseX, mouseY, clickCount) -end - ---- Handle mouse drag for text selection ---- Delegates to the TextEditable behavior, which owns drag tracking. ----@param mouseX number -- Mouse X coordinate ----@param mouseY number -- Mouse Y coordinate -function Element:_handleTextDrag(mouseX, mouseY) - return Element._TextEditable._handleTextDrag(self, mouseX, mouseY) -end - --- ==================== --- Input Handling - Keyboard Input (behavior-delegated, task 04) --- ==================== - ---- Handle text input (character input) — delegates to the TextEditable behavior. ----@param text string -- Character(s) to insert -function Element:textinput(text) - return Element._TextEditable.textinput(self, text) -end - ---- Handle key press (special keys) — delegates to the TextEditable behavior. ----@param key string -- Key name ----@param scancode string -- Scancode ----@param isrepeat boolean -- Whether this is a key repeat -function Element:keypressed(key, scancode, isrepeat) - return Element._TextEditable.keypressed(self, key, scancode, isrepeat) -end - --- ==================== --- Performance Monitoring --- ==================== - ---- Get hierarchy depth of this element ----@return number depth Depth in the element tree (0 for root) -function Element:getHierarchyDepth() - local depth = 0 - local current = self.parent - while current do - depth = depth + 1 - current = current.parent - end - return depth -end - ---- Count total elements in this tree ----@return number count Total number of elements including this one and all descendants -function Element:countElements() - local count = 1 -- Count self - for _, child in ipairs(self.children) do - count = count + child:countElements() - end - return count -end - -function Element:_checkPerformanceWarnings() - if not Element._Performance or not Element._Performance.warningsEnabled then - return - end - - -- Check hierarchy depth - local depth = self:getHierarchyDepth() - if depth >= 15 then - Element._Performance:logWarning( - string.format("hierarchy_depth_%s", self.id), - "Element", - string.format("Element hierarchy depth is %d levels for element '%s'", depth, self.id or "unnamed"), - { depth = depth, elementId = self.id or "unnamed" }, - "Deep nesting can impact performance. Consider flattening the structure or using absolute positioning" - ) - end - - -- Check total element count (only for root elements) - if not self.parent then - local totalElements = self:countElements() - if totalElements >= 1000 then - Element._Performance:logWarning( - "element_count_high", - "Element", - string.format("UI contains %d+ elements", totalElements), - { elementCount = totalElements }, - "Large element counts may impact performance. Consider virtualization for long lists or pagination for large datasets" - ) - end - end -end - ---- Count active animations in tree ----@return number count Number of active animations -function Element:_countActiveAnimations() - local count = self.animation and 1 or 0 - for _, child in ipairs(self.children) do - count = count + child:_countActiveAnimations() - end - return count -end - ---- Track active animations and warn if too many -function Element:_trackActiveAnimations() - -- Get Performance instance from deps if available - if not Element._Performance or not Element._Performance.warningsEnabled then - return - end - - local animCount = self:_countActiveAnimations() - if animCount >= 50 then - Element._Performance:logWarning( - "animation_count_high", - "Element", - string.format("%d+ animations running simultaneously", animCount), - { animationCount = animCount }, - "High animation counts may impact frame rate. Consider reducing concurrent animations or using CSS-style transitions" - ) - end -end - ---- Change the tint color of an image element dynamically for hover effects or state indication ---- Use this to recolor images without replacing the asset, like highlighting selected items ----@param color Color Color to tint the image -function Element:setImageTint(color) - self.imageTint = color -end - ---- Adjust image transparency independently from the element for fade effects ---- Use this to create image-specific fade animations or disabled states ----@param opacity number Opacity 0-1 -function Element:setImageOpacity(opacity) - if opacity ~= nil then - Element._utils.validateRange(opacity, 0, 1, "imageOpacity") - end - self.imageOpacity = opacity -end - ---- Set image repeat mode ----@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round" -function Element:setImageRepeat(repeatMode) - local validImageRepeat = { - ["no-repeat"] = "no-repeat", - ["repeat"] = "repeat", - ["repeat-x"] = "repeat-x", - ["repeat-y"] = "repeat-y", - space = "space", - round = "round", - } - Element._utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat") - self.imageRepeat = repeatMode -end - ---- Apply rotation transform to create spinning animations or rotated layouts ---- Use this for loading spinners, compass needles, or angled UI elements ----@param angle number Angle in radians -function Element:rotate(angle) - if not self.transform then - self.transform = Element._Transform.new({}) - end - self.transform.rotate = angle -end - ---- Resize element visually using scale transforms for zoom effects ---- Use this for hover magnification, shrinking animations, or responsive scaling ----@param scaleX number X-axis scale ----@param scaleY number? Y-axis scale (defaults to scaleX) -function Element:scale(scaleX, scaleY) - if not self.transform then - self.transform = Element._Transform.new({}) - end - self.transform.scaleX = scaleX - self.transform.scaleY = scaleY or scaleX -end - ---- Offset element position using transforms for smooth movement without layout recalculation ---- Use this for parallax effects, draggable elements, or position animations ----@param x number X translation ----@param y number Y translation -function Element:translate(x, y) - if not self.transform then - self.transform = Element._Transform.new({}) - end - self.transform.translateX = x - self.transform.translateY = y -end - ---- Define the pivot point for rotation and scaling transforms ---- Use this to rotate around corners, edges, or custom points rather than the center ----@param originX number X origin (0-1, where 0.5 is center) ----@param originY number Y origin (0-1, where 0.5 is center) -function Element:setTransformOrigin(originX, originY) - if not self.transform then - self.transform = Element._Transform.new({}) - end - self.transform.originX = originX - self.transform.originY = originY -end - ---- Animate element to new property values with automatic transition ---- Captures current values as start, uses provided values as final, and applies the animation ----@param props table Target property values ----@param duration number? Animation duration in seconds (default: 0.3) ----@param easing string? Easing function name (default: "linear") ----@return Element self For method chaining -function Element:animateTo(props, duration, easing) - if not Element._Animation then - _warnAnimApi("ELEM_003") - return self - end - - if type(props) ~= "table" then - _warnAnimApi("ELEM_003") - return self - end - - duration = duration or 0.3 - easing = easing or "linear" - - -- Collect current values as start - local startValues = {} - for key, _ in pairs(props) do - startValues[key] = self[key] - end - - -- Create and apply animation - local anim = Element._Animation.new({ - duration = duration, - start = startValues, - final = props, - easing = easing, - }) - - anim:apply(self) - return self -end - ---- Fade element to full opacity ----@param duration number? Duration in seconds (default: 0.3) ----@param easing string? Easing function name ----@return Element self For method chaining -function Element:fadeIn(duration, easing) - return self:animateTo({ opacity = 1 }, duration or 0.3, easing) -end - ---- Fade element to zero opacity ----@param duration number? Duration in seconds (default: 0.3) ----@param easing string? Easing function name ----@return Element self For method chaining -function Element:fadeOut(duration, easing) - return self:animateTo({ opacity = 0 }, duration or 0.3, easing) -end - ---- Scale element to target scale value using transforms ----@param targetScale number Target scale multiplier ----@param duration number? Duration in seconds (default: 0.3) ----@param easing string? Easing function name ----@return Element self For method chaining -function Element:scaleTo(targetScale, duration, easing) - if not Element._Animation or not Element._Transform then - _warnAnimApi("ELEM_003") - return self - end - - -- Ensure element has a transform - if not self.transform then - self.transform = Element._Transform.new({}) - end - - local currentScaleX = self.transform.scaleX or 1 - local currentScaleY = self.transform.scaleY or 1 - - local anim = Element._Animation.new({ - duration = duration or 0.3, - start = { scaleX = currentScaleX, scaleY = currentScaleY }, - final = { scaleX = targetScale, scaleY = targetScale }, - easing = easing or "linear", - }) - - anim:apply(self) - return self -end - ---- Move element to target position ----@param x number Target x position ----@param y number Target y position ----@param duration number? Duration in seconds (default: 0.3) ----@param easing string? Easing function name ----@return Element self For method chaining -function Element:moveTo(x, y, duration, easing) - return self:animateTo({ x = x, y = y }, duration or 0.3, easing) -end - ---- Set transition configuration for a property ----@param property string Property name or "all" for all properties ----@param config table Transition config {duration, easing, delay, onComplete} -function Element:setTransition(property, config) - if not self.transitions then - self.transitions = {} - end - - if type(config) ~= "table" then - _warnAnimApi("ELEM_003") - config = {} - end - - -- Validate config - if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then - _warnAnimApi("ELEM_004", config.duration) - config.duration = 0.3 - end - - self.transitions[property] = { - duration = config.duration or 0.3, - easing = config.easing or "easeOutQuad", - delay = config.delay or 0, - onComplete = config.onComplete, - } -end - ---- Set transition configuration for multiple properties ----@param groupName string Name for this transition group ----@param config table Transition config {duration, easing, delay, onComplete} ----@param properties table Array of property names -function Element:setTransitionGroup(_, config, properties) - if type(properties) ~= "table" then - _warnAnimApi("ELEM_005") - return - end - - for _, prop in ipairs(properties) do - self:setTransition(prop, config) - end -end - ---- Remove transition configuration for a property ----@param property string Property name or "all" to remove all -function Element:removeTransition(property) - if not self.transitions then - return - end - - if property == "all" then - self.transitions = {} - else - self.transitions[property] = nil - end -end - ---- Resolve a unit-based dimension property (width/height) from a string or CalcObject ---- Parses the value, updates self.units, resolves to pixels, and updates border-box dimensions ----@param property string "width" or "height" ----@param value string|table The unit string (e.g., "50%", "10vw") or CalcObject ----@return number resolvedValue The resolved pixel value -function Element:_resolveDimensionProperty(property, value) - local viewportWidth, viewportHeight = Element._Units.getViewport() - local parsedValue, parsedUnit = Element._Units.parse(value) - self.units[property] = { value = parsedValue, unit = parsedUnit } - - local parentDimension - if property == "width" then - parentDimension = self.parent and self.parent.width or viewportWidth - else - parentDimension = self.parent and self.parent.height or viewportHeight - end - - local resolved = Element._Units.resolve(parsedValue, parsedUnit, viewportWidth, viewportHeight, parentDimension) - - if type(resolved) ~= "number" then - Element._ErrorHandler:warn("Element", "LAY_003", { - issue = string.format("%s resolution returned non-number value", property), - type = type(resolved), - value = tostring(resolved), - }) - resolved = 0 - end - - self[property] = resolved - - if property == "width" then - if self.autosizing and self.autosizing.width then - self._borderBoxWidth = resolved + self.padding.left + self.padding.right - else - self._borderBoxWidth = resolved - end - else - if self.autosizing and self.autosizing.height then - self._borderBoxHeight = resolved + self.padding.top + self.padding.bottom - else - self._borderBoxHeight = resolved - end - end - - return resolved -end - ---- Resolve a dimension (width/height) prop given a unit-string/Calc value. ---- Handles the unit-sameness short-circuit, transition-on-resolved-pixel-value ---- semantics, and layout invalidation. Exits setProperty (caller returns). -local function _setDimensionWithUnit(self, property, value, transitionConfig) - -- Check if the unit specification is the same (compare against stored units) - local currentUnits = self.units[property] - local newValue, newUnit = Element._Units.parse(value) - if currentUnits and currentUnits.value == newValue and currentUnits.unit == newUnit then - return - end - - if transitionConfig then - -- For transitions, resolve the target value and transition the pixel value - local currentPixelValue = self[property] - local resolvedTarget = self:_resolveDimensionProperty(property, value) - - if currentPixelValue ~= nil and currentPixelValue ~= resolvedTarget then - -- Reset to current value before animating - self[property] = currentPixelValue - local Animation = require("modules.Animation") - local anim = Animation.new({ - duration = transitionConfig.duration, - start = { [property] = currentPixelValue }, - final = { [property] = resolvedTarget }, - easing = transitionConfig.easing, - onComplete = transitionConfig.onComplete, - }) - anim:apply(self) - end - else - self:_resolveDimensionProperty(property, value) - end - - self:invalidateLayout() -end - ---- Apply a transition animation from the current value to `value` for `property`. ---- Falls back to a direct write when there is no current value to animate from. -local function _animatePropertyTo(self, property, value, transitionConfig) - local currentValue = self[property] - if currentValue ~= nil then - local Animation = require("modules.Animation") - local anim = Animation.new({ - duration = transitionConfig.duration, - start = { [property] = currentValue }, - final = { [property] = value }, - easing = transitionConfig.easing, - onComplete = transitionConfig.onComplete, - }) - anim:apply(self) - else - self[property] = value - end -end - --- Explicit handler map for the few props with genuinely-different setProperty --- semantics that cannot be expressed via schema flags alone. Adding a new prop --- with ordinary behavior requires NO new entry here — it flows through the --- generic flagged dispatch below. Handlers signal a full-handled early return. -local _specialSetHandlers = { - parent = function(self, value) - self:setParent(value) - return true - end, - themeComponent = function(self, value) - self.themeComponent = value - self:_syncThemeAndRenderer("themeComponent", value) - return true - end, - -- imagePath / image: setting these must re-run the Imageable load pipeline - -- (recompute `_loadedImage`, fire onImageLoad/onImageError, defer I/O). The - -- Imageable behavior installs `element._reloadImage` at construction; if it - -- is absent the element has no image concern (Imageable only attaches when - -- imagePath/image is declared at construction), so the field is set but no - -- load occurs — late image-concern acquisition requires re-attaching the - -- behavior, which is outside the attach-at-construction contract. - imagePath = function(self, value) - self.imagePath = value - if self._reloadImage then - self:_reloadImage() - end - return true - end, - image = function(self, value) - self.image = value - if self._reloadImage then - self:_reloadImage() - end - return true - end, -} - ---- Set property with automatic transition. ---- Dispatch is registry-driven: dimension/unit props route through ---- `_setDimensionWithUnit`, the genuinely-special props (parent, ---- themeComponent, imagePath, image) route through `_specialSetHandlers`, and ---- everything else is a single generic path that consults schema flags ---- (`affectsLayout` / `syncsTheme`) for layout invalidation and theme sync. No ---- inline hardcoded property-name branches and no per-call table allocation. ----@param property string Property name ----@param value any New value -function Element:setProperty(property, value) - local transitionConfig - if self.transitions then - transitionConfig = self.transitions[property] or self.transitions["all"] - end - - local schema = Element._PropertySchema - - -- 1. Dimension prop with a unit string / CalcObject: resolve to pixels. - if schema.isDimension(property) and (type(value) == "string" or (Element._Calc and Element._Calc.isCalc(value))) then - _setDimensionWithUnit(self, property, value, transitionConfig) - return - end - - -- 2. Genuinely-special props (parent reparenting, themeComponent sync). - local handler = _specialSetHandlers[property] - if handler then - handler(self, value) - return - end - - -- 3. Generic flagged dispatch. - -- Skip write/transition/layout work for unchanged values, but still sync - -- theme state: disabled/active must reach setThemeState even when the value is - -- unchanged (renderer/theme state may have been reset out-of-band). - if self[property] ~= value then - if transitionConfig then - _animatePropertyTo(self, property, value, transitionConfig) - else - self[property] = value - end - if schema.affectsLayout(property) then - self:invalidateLayout() - end - end - if schema.syncsTheme(property) then - self:_syncThemeAndRenderer(property, value) - end -end - ----Sync ThemeManager and Renderer when properties change that affect rendering ----@param property string The property name that changed ----@param value any The new value -function Element:_syncThemeAndRenderer(property, value) - -- Visual props (backgroundColor/borderColor/cornerRadius/opacity) and callbacks - -- (onEvent/onTouchEvent/onGesture) are intentionally NOT synced here: Renderer:draw - -- and EventHandler dispatch read them from the element as source of truth, so a - -- bare `element. = v` write is immediately consistent with setProperty(...). - -- Only stateful side effects (theme-state machine + themeManager component) remain. - if property == "disabled" then - if self._themeManager then - self._themeManager.disabled = value - end - if self._renderer then - self._renderer:setThemeState(value and "disabled" or "normal") - end - elseif property == "active" then - if self._themeManager then - self._themeManager.active = value - end - if self._renderer then - self._renderer:setThemeState(value and "active" or "normal") - end - elseif property == "themeComponent" then - if self._themeManager then - self._themeManager.themeComponent = value - end - end -end - --- ==================== --- State Persistence (behavior-mode-unification task 12) --- ==================== - ---- Save all element state for immediate-mode persistence. ---- Each attached behavior owns its own state extraction (saveState hook) and ---- returns a snapshot (or nil) merged into the consolidated state table: ---- Clickable → `eventHandler`, Scrollable → `scrollManager`, TextEditable → ---- `textEditor` + drag tracking, Selectable → `select`, Themed → `blur`, ---- Persistable → `_props` (public scalar mutations). Element owns ZERO ---- per-subsystem extraction logic — this method is a pure dispatch loop. ----@return ElementStateData state Complete state snapshot -function Element:saveState() - local state = {} - for i = 1, #self.behaviors do - local bstate = self.behaviors[i].saveState(self) - if bstate ~= nil then - for k, v in pairs(bstate) do - state[k] = v - end - end - end - return state -end - ---- Restore all element state from StateManager. ---- Each attached behavior owns its own hydration (restoreState hook) and reads ---- only its own slice from the full state table. Registry order places ---- Persistable last so `_props` overrides subsystem-hydrated state, preserving ---- the legacy restore ordering. Element owns ZERO per-subsystem hydration. ----@param state ElementStateData State to restore -function Element:restoreState(state) - if not state then - return - end - for i = 1, #self.behaviors do - self.behaviors[i].restoreState(self, state) - end -end - ---- Cleanup method to break circular references (immediate-mode frame end). ---- Iterates each attached behavior's `onDetach` hook so every behavior tears ---- down what its `onAttach` created (Clickable releases the EventHandler, ---- TextEditable the TextEditor, Themed the Renderer, Selectable the select ---- fields, Imageable the image callbacks), then clears the behaviors list and ---- unregisters from StateManager. Does NOT clear onEvent / onTouchEvent / ---- onGesture — the Renderer/EventHandler read those directly from the element ---- (not the cache), so clearing them here would break retained mode. -function Element:_cleanup() - for i = 1, #self.behaviors do - self.behaviors[i].onDetach(self) - end - self.behaviors = {} - -- onCreate fires once at construction (already invoked by now); release it. - self.onCreate = nil - if self._stateId and self._stateId ~= "" then - Element._StateManager.unregisterStateful(self._stateId) - end -end - --- ==================== --- Keyboard Navigation --- ==================== - ---- Check if this element can receive keyboard focus ----@return boolean -function Element:isFocusable() - if self.disabled then - return false - end - -- Capability query: an element is keyboard-focusable when it is editable, has - -- an event/text handler, participates in the Select subsystem, or is a - -- touch-interactive element with callbacks. Expressed as a single boolean - -- expression (not a dispatch branch) because focusability is a query, not a - -- per-frame behavior. - return not not ( - self.editable - or type(self.onEvent) == "function" - or self._selectState - or self.selectOption - or self.onTextInput - or (self.touchEnabled and (self.onTouchEvent or self.onGesture)) - ) -end - ---- Get all focusable children in DOM/document order (depth-first traversal) ---- Elements are collected in the order they appear in the children array, ---- with nested children collected after their parent. This matches standard ---- browser tab order behavior where elements are ordered by document position. ----@return Element[] -function Element:getFocusableChildren() - local focusable = {} - - local function collectFocusable(elem) - for _, child in ipairs(elem.children) do - -- Check self first - if child:isFocusable() then - table.insert(focusable, child) - end - - -- Then recurse (depth-first) - collectFocusable(child) - end - end - - collectFocusable(self) - return focusable -end - ---- Get next focusable element in sequence ----@param container Element The container element ----@param currentElement Element? Current focused element ----@param wrap boolean? Whether to wrap around ----@return Element? -function Element.getNextFocusable(container, currentElement, wrap) - local focusable = container:getFocusableChildren() - if #focusable == 0 then - return nil - end - - -- Find current index - local currentIndex = 0 - if currentElement then - for i, elem in ipairs(focusable) do - if elem == currentElement then - currentIndex = i - break - end - end - end - - -- Find next - local nextIndex = currentIndex + 1 - if nextIndex > #focusable then - if wrap then - nextIndex = 1 - else - return nil - end - end - - return focusable[nextIndex] -end - ---- Get previous focusable element in sequence ----@param container Element The container element ----@param currentElement Element? Current focused element ----@param wrap boolean? Whether to wrap around ----@return Element? -function Element.getPreviousFocusable(container, currentElement, wrap) - local focusable = container:getFocusableChildren() - if #focusable == 0 then - return nil - end - - -- Find current index - local currentIndex = #focusable + 1 - if currentElement then - for i, elem in ipairs(focusable) do - if elem == currentElement then - currentIndex = i - break - end - end - end - - -- Find previous - local prevIndex = currentIndex - 1 - if prevIndex < 1 then - if wrap then - prevIndex = #focusable - else - return nil - end - end - - return focusable[prevIndex] -end - -return Element diff --git a/libs/flexlove/modules/Enums.lua b/libs/flexlove/modules/Enums.lua deleted file mode 100644 index 9e94b4c1..00000000 --- a/libs/flexlove/modules/Enums.lua +++ /dev/null @@ -1,171 +0,0 @@ --- Layout, flex, text, image, and ARIA enums used across FlexLove. --- Extracted from utils so utils stays under its LOC budget; re-exported as --- `utils.enums` for backward compatibility. - -local enums = { - ---@enum TextAlign - TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, - ---@enum TextAlignVertical - TextAlignVertical = { START = "start", CENTER = "center", END = "end" }, - ---@enum Positioning - Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" }, - ---@enum FlexDirection - FlexDirection = { - HORIZONTAL = "horizontal", - VERTICAL = "vertical", - ROW = "row", - COLUMN = "column", - HORIZONTAL_REVERSE = "horizontal-reverse", - VERTICAL_REVERSE = "vertical-reverse", - ROW_REVERSE = "row-reverse", - COLUMN_REVERSE = "column-reverse", - }, - ---@enum JustifyContent - JustifyContent = { - FLEX_START = "flex-start", - CENTER = "center", - SPACE_AROUND = "space-around", - FLEX_END = "flex-end", - SPACE_EVENLY = "space-evenly", - SPACE_BETWEEN = "space-between", - }, - ---@enum JustifySelf - JustifySelf = { - AUTO = "auto", - FLEX_START = "flex-start", - CENTER = "center", - FLEX_END = "flex-end", - SPACE_AROUND = "space-around", - SPACE_EVENLY = "space-evenly", - SPACE_BETWEEN = "space-between", - }, - ---@enum AlignItems - AlignItems = { - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - BASELINE = "baseline", - }, - ---@enum AlignSelf - AlignSelf = { - AUTO = "auto", - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - BASELINE = "baseline", - }, - ---@enum AlignContent - AlignContent = { - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - SPACE_BETWEEN = "space-between", - SPACE_AROUND = "space-around", - }, - ---@enum FlexWrap - FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" }, - ---@enum TextSize - TextSize = { - XXS = "xxs", - XS = "xs", - SM = "sm", - MD = "md", - LG = "lg", - XL = "xl", - XXL = "xxl", - XL3 = "3xl", - XL4 = "4xl", - }, - ---@enum ImageRepeat - ImageRepeat = { - NO_REPEAT = "no-repeat", - REPEAT = "repeat", - REPEAT_X = "repeat-x", - REPEAT_Y = "repeat-y", - SPACE = "space", - ROUND = "round", - }, - - ---@enum ARIA Role (accessibility roles for screen readers) - ARIA = { - -- Widget roles - BUTTON = "button", - CHECKBOX = "checkbox", - LINK = "link", - MENUITEM = "menuitem", - MENUITEMCHECKBOX = "menuitemcheckbox", - MENUITEMRADIO = "menuitemradio", - PROGRESSBAR = "progressbar", - RADIO = "radio", - SCROLLBAR = "scrollbar", - SLIDER = "slider", - SPINBUTTON = "spinbutton", - SWITCH = "switch", - TAB = "tab", - TABLIST = "tablist", - TABPANEL = "tabpanel", - TEXTBOX = "textbox", - TOOLTIP = "tooltip", - TREEITEM = "treeitem", - COMBOBOX = "combobox", - GRID = "grid", - GRIDCELL = "gridcell", - LISTBOX = "listbox", - LISTITEM = "listitem", - MENU = "menu", - MENUBAR = "menubar", - TREE = "tree", - TREEGRID = "treegrid", - WINDOW = "window", - DIALOG = "dialog", - ALERTDIALOG = "alertdialog", - - -- Landmark roles - BANNER = "banner", - COMPLEMENTARY = "complementary", - CONTENTINFO = "contentinfo", - FORM = "form", - MAIN = "main", - NAVIGATION = "navigation", - REGION = "region", - SEARCH = "search", - - -- Live region roles - ALERT = "alert", - LOG = "log", - MARQUEE = "marquee", - STATUS = "status", - TIMERTIME = "timer", - - -- Document structure roles - ARTICLE = "article", - BLOCKQUOTEBLOCKQUOTE = "blockquote", - CAPTION = "caption", - CODE = "code", - DEFINITION = "definition", - DELETED = "deletion", - DIRECTORY = "directory", - DIVISION = "division", - EMphasis = "emphasis", - HEADING = "heading", - INSERTED = "insertion", - LIST = "list", - MARK = "mark", - MATH = "math", - NONE = "none", - PARAGRAPH = "paragraph", - PRESENTATION = "presentation", - SEPARATOR = "separator", - STRONG = "strong", - SUBSCRIPT = "subscript", - SUPERSCRIPT = "superscript", - TERM = "term", - TIME = "time", - VARIABLE = "variable", - }, -} - -return { enums = enums } diff --git a/libs/flexlove/modules/ErrorHandler.lua b/libs/flexlove/modules/ErrorHandler.lua deleted file mode 100644 index 2f930619..00000000 --- a/libs/flexlove/modules/ErrorHandler.lua +++ /dev/null @@ -1,1042 +0,0 @@ ----@class ErrorCodes ----@field categories table ----@field codes table -local ErrorCodes = { - categories = { - VAL = "Validation", - LAY = "Layout", - REN = "Render", - THM = "Theme", - EVT = "Event", - RES = "Resource", - SYS = "System", - }, - codes = { - -- Validation Errors (VAL_001 - VAL_099) - VAL_001 = { - code = "FLEXLOVE_VAL_001", - category = "VAL", - description = "Invalid property type", - suggestion = "Check the property type matches the expected type (e.g., number, string, table)", - }, - VAL_002 = { - code = "FLEXLOVE_VAL_002", - category = "VAL", - description = "Property value out of range", - suggestion = "Ensure the value is within the allowed min/max range", - }, - VAL_003 = { - code = "FLEXLOVE_VAL_003", - category = "VAL", - description = "Required property missing", - suggestion = "Provide the required property in your element definition", - }, - VAL_004 = { - code = "FLEXLOVE_VAL_004", - category = "VAL", - description = "Invalid color format", - suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object", - }, - VAL_005 = { - code = "FLEXLOVE_VAL_005", - category = "VAL", - description = "Invalid unit format", - suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.", - }, - VAL_006 = { - code = "FLEXLOVE_VAL_006", - category = "VAL", - description = "Invalid calc() expression or calculation error", - suggestion = "Check calc() syntax and ensure no division by zero. Format: calc('value1 operator value2') with operators: +, -, *, / and units: px, %, vw, vh", - }, - VAL_007 = { - code = "FLEXLOVE_VAL_007", - category = "VAL", - description = "Invalid enum value", - suggestion = "Use one of the allowed enum values for this property", - }, - VAL_008 = { - code = "FLEXLOVE_VAL_008", - category = "VAL", - description = "Invalid text input", - suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)", - }, - - -- Layout Errors (LAY_001 - LAY_099) - LAY_001 = { - code = "FLEXLOVE_LAY_001", - category = "LAY", - description = "Invalid flex direction", - suggestion = "Use 'horizontal' or 'vertical' for flexDirection", - }, - LAY_002 = { - code = "FLEXLOVE_LAY_002", - category = "LAY", - description = "Circular dependency detected", - suggestion = "Remove circular references in element hierarchy or layout constraints", - }, - LAY_003 = { - code = "FLEXLOVE_LAY_003", - category = "LAY", - description = "Invalid dimensions (negative or NaN)", - suggestion = "Ensure width and height are positive numbers", - }, - LAY_004 = { - code = "FLEXLOVE_LAY_004", - category = "LAY", - description = "Layout calculation overflow", - suggestion = "Reduce complexity of layout or increase recursion limit", - }, - LAY_005 = { - code = "FLEXLOVE_LAY_005", - category = "LAY", - description = "Invalid alignment value", - suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)", - }, - LAY_006 = { - code = "FLEXLOVE_LAY_006", - category = "LAY", - description = "Invalid positioning mode", - suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning", - }, - LAY_007 = { - code = "FLEXLOVE_LAY_007", - category = "LAY", - description = "Grid layout error", - suggestion = "Check grid template columns/rows and item placement", - }, - LAY_008 = { - code = "FLEXLOVE_LAY_008", - category = "LAY", - description = "Explicit position will be ignored by flex layout", - suggestion = "Remove x/y properties (flex layout controls position), OR set positioning='absolute' with left/top/right/bottom properties. Additionally, you can use margin/padding for positional offsets in flex layouts.", - }, - LAY_009 = { - code = "FLEXLOVE_LAY_009", - category = "LAY", - description = "Flex layout properties ignored with grid positioning", - suggestion = "Remove flexDirection/justifyContent/alignItems properties, or change positioning to 'flex' or 'relative'", - }, - LAY_010 = { - code = "FLEXLOVE_LAY_010", - category = "LAY", - description = "Grid layout properties ignored without grid positioning", - suggestion = "Set positioning='grid' to use grid layout properties, or remove grid properties", - }, - LAY_011 = { - code = "FLEXLOVE_LAY_011", - category = "LAY", - description = "CSS positioning properties ignored", - suggestion = "Set positioning='absolute' to use top/bottom/left/right properties", - }, - - -- Rendering Errors (REN_001 - REN_099) - REN_001 = { - code = "FLEXLOVE_REN_001", - category = "REN", - description = "Invalid render state", - suggestion = "Ensure element is properly initialized before rendering", - }, - REN_002 = { - code = "FLEXLOVE_REN_002", - category = "REN", - description = "Texture loading failed", - suggestion = "Check image path and format, ensure file exists", - }, - REN_003 = { - code = "FLEXLOVE_REN_003", - category = "REN", - description = "Font loading failed", - suggestion = "Check font path and format, ensure file exists", - }, - REN_004 = { - code = "FLEXLOVE_REN_004", - category = "REN", - description = "Invalid color value", - suggestion = "Color components must be numbers between 0 and 1", - }, - REN_005 = { - code = "FLEXLOVE_REN_005", - category = "REN", - description = "Clipping stack overflow", - suggestion = "Reduce nesting depth or check for missing scissor pops", - }, - REN_006 = { - code = "FLEXLOVE_REN_006", - category = "REN", - description = "Shader compilation failed", - suggestion = "Check shader code for syntax errors", - }, - REN_007 = { - code = "FLEXLOVE_REN_007", - category = "REN", - description = "Invalid nine-patch configuration", - suggestion = "Check nine-patch slice values and image dimensions", - }, - - -- Theme Errors (THM_001 - THM_099) - THM_001 = { - code = "FLEXLOVE_THM_001", - category = "THM", - description = "Theme file not found", - suggestion = "Check theme file path and ensure file exists", - }, - THM_002 = { - code = "FLEXLOVE_THM_002", - category = "THM", - description = "Invalid theme structure", - suggestion = "Theme must return a table with 'name' and component styles", - }, - THM_003 = { - code = "FLEXLOVE_THM_003", - category = "THM", - description = "Required theme property missing", - suggestion = "Ensure theme has required properties (name, base styles, etc.)", - }, - THM_004 = { - code = "FLEXLOVE_THM_004", - category = "THM", - description = "Invalid component style", - suggestion = "Component styles must be tables with valid properties", - }, - THM_005 = { - code = "FLEXLOVE_THM_005", - category = "THM", - description = "Theme loading failed", - suggestion = "Check theme file for Lua syntax errors", - }, - THM_006 = { - code = "FLEXLOVE_THM_006", - category = "THM", - description = "Invalid theme color", - suggestion = "Theme colors must be valid color values (hex, rgba, Color object)", - }, - THM_007 = { - code = "FLEXLOVE_THM_007", - category = "THM", - description = "themeStateLock has no effect without a valid theme component", - suggestion = "Ensure themeComponent is set and valid when using themeStateLock", - }, - THM_008 = { - code = "FLEXLOVE_THM_008", - category = "THM", - description = "Theme component has no state variants", - suggestion = "themeStateLock has no effect on components without state variants", - }, - THM_009 = { - code = "FLEXLOVE_THM_009", - category = "THM", - description = "Requested theme state does not exist", - suggestion = "Use one of the available theme states or set themeStateLock to false", - }, - THM_010 = { - code = "FLEXLOVE_THM_010", - category = "THM", - description = "Invalid themeStateLock type", - suggestion = "themeStateLock must be boolean or string (state name)", - }, - - -- Event Errors (EVT_001 - EVT_099) - EVT_001 = { - code = "FLEXLOVE_EVT_001", - category = "EVT", - description = "Invalid event type", - suggestion = "Use valid event types (mousepressed, textinput, etc.)", - }, - EVT_002 = { - code = "FLEXLOVE_EVT_002", - category = "EVT", - description = "Event handler error", - suggestion = "Check event handler function for errors", - }, - EVT_003 = { - code = "FLEXLOVE_EVT_003", - category = "EVT", - description = "Event propagation error", - suggestion = "Check event bubbling/capturing logic", - }, - EVT_004 = { - code = "FLEXLOVE_EVT_004", - category = "EVT", - description = "Invalid event target", - suggestion = "Ensure event target element exists and is valid", - }, - EVT_005 = { - code = "FLEXLOVE_EVT_005", - category = "EVT", - description = "Event handler not a function", - suggestion = "Event handlers must be functions", - }, - - -- Resource Errors (RES_001 - RES_099) - RES_001 = { - code = "FLEXLOVE_RES_001", - category = "RES", - description = "File not found", - suggestion = "Check file path and ensure file exists in the filesystem", - }, - RES_002 = { - code = "FLEXLOVE_RES_002", - category = "RES", - description = "Permission denied", - suggestion = "Check file permissions and access rights", - }, - RES_003 = { - code = "FLEXLOVE_RES_003", - category = "RES", - description = "Invalid file format", - suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)", - }, - RES_004 = { - code = "FLEXLOVE_RES_004", - category = "RES", - description = "Resource loading failed", - suggestion = "Check file integrity and format compatibility", - }, - RES_005 = { - code = "FLEXLOVE_RES_005", - category = "RES", - description = "Image cache error", - suggestion = "Clear image cache or check memory availability", - }, - - -- System Errors (SYS_001 - SYS_099) - SYS_001 = { - code = "FLEXLOVE_SYS_001", - category = "SYS", - description = "Memory allocation failed", - suggestion = "Reduce memory usage or check available memory", - }, - SYS_002 = { - code = "FLEXLOVE_SYS_002", - category = "SYS", - description = "Stack overflow", - suggestion = "Reduce recursion depth or check for infinite loops", - }, - SYS_003 = { - code = "FLEXLOVE_SYS_003", - category = "SYS", - description = "Invalid state", - suggestion = "Check initialization order and state management", - }, - SYS_004 = { - code = "FLEXLOVE_SYS_004", - category = "SYS", - description = "Module initialization failed", - suggestion = "Check module dependencies and initialization order", - }, - - -- Performance Warnings (PERF_001 - PERF_099) - PERF_001 = { - code = "FLEXLOVE_PERF_001", - category = "PERF", - description = "Performance threshold exceeded", - suggestion = "Operation took longer than recommended. Monitor for patterns.", - }, - PERF_002 = { - code = "FLEXLOVE_PERF_002", - category = "PERF", - description = "Critical performance threshold exceeded", - suggestion = "Operation is causing frame drops. Consider optimizing or reducing frequency.", - }, - PERF_003 = { - code = "FLEXLOVE_PERF_003", - category = "PERF", - description = "Large blur area in immediate mode", - suggestion = "Consider using retained mode for this component to avoid recreating blur effects every frame.", - }, - - -- Memory Warnings (MEM_001 - MEM_099) - MEM_001 = { - code = "FLEXLOVE_MEM_001", - category = "MEM", - description = "Memory leak detected", - suggestion = "Table is growing consistently. Review cache eviction policies and ensure objects are properly released.", - }, - - -- State Management Warnings (STATE_001 - STATE_099) - STATE_001 = { - code = "FLEXLOVE_STATE_001", - category = "STATE", - description = "CallSite counters accumulating", - suggestion = "This indicates incrementFrame() may not be called properly. Check immediate mode frame management.", - }, - - -- Animation Errors (ANIM_001 - ANIM_099) - ANIM_001 = { - code = "FLEXLOVE_ANIM_001", - category = "VAL", - description = "Invalid animation configuration", - suggestion = "Animation.new() requires a table argument with duration, start, and final properties", - }, - ANIM_002 = { - code = "FLEXLOVE_ANIM_002", - category = "VAL", - description = "Invalid animation duration", - suggestion = "Animation duration must be a positive number in seconds", - }, - ANIM_003 = { - code = "FLEXLOVE_ANIM_003", - category = "VAL", - description = "Invalid animation target", - suggestion = "Animation can only be applied to table elements", - }, - ANIM_004 = { - code = "FLEXLOVE_ANIM_004", - category = "VAL", - description = "Invalid animation chain", - suggestion = "chain() requires an Animation object or function", - }, - ANIM_005 = { - code = "FLEXLOVE_ANIM_005", - category = "VAL", - description = "Invalid animation delay", - suggestion = "delay() requires a non-negative number in seconds", - }, - ANIM_006 = { - code = "FLEXLOVE_ANIM_006", - category = "VAL", - description = "Invalid repeat count", - suggestion = "repeatCount() requires a non-negative number", - }, - ANIM_007 = { - code = "FLEXLOVE_ANIM_007", - category = "VAL", - description = "Invalid keyframes configuration", - suggestion = "Animation.keyframes() requires a table with duration and keyframes array", - }, - ANIM_008 = { - code = "FLEXLOVE_ANIM_008", - category = "VAL", - description = "Insufficient keyframes", - suggestion = "Keyframe animations require at least 2 keyframes", - }, - ANIM_009 = { - code = "FLEXLOVE_ANIM_009", - category = "VAL", - description = "Invalid animation group configuration", - suggestion = "AnimationGroup.new() requires a table with animations array", - }, - ANIM_010 = { - code = "FLEXLOVE_ANIM_010", - category = "VAL", - description = "Empty animation group", - suggestion = "AnimationGroup requires at least one animation", - }, - ANIM_011 = { - code = "FLEXLOVE_ANIM_011", - category = "VAL", - description = "Invalid animation group mode", - suggestion = "AnimationGroup mode must be 'parallel' or 'sequence'", - }, - - -- Blur Errors (BLUR_001 - BLUR_099) - BLUR_001 = { - code = "FLEXLOVE_BLUR_001", - category = "VAL", - description = "Missing draw function", - suggestion = "applyToRegion requires a draw function to render the content to be blurred", - }, - BLUR_002 = { - code = "FLEXLOVE_BLUR_002", - category = "VAL", - description = "Missing backdrop canvas", - suggestion = "applyBackdrop requires a backdrop canvas parameter", - }, - - -- FlexLove Core Errors (CORE_001 - CORE_099) - CORE_001 = { - code = "FLEXLOVE_CORE_001", - category = "VAL", - description = "Invalid callback function", - suggestion = "deferCallback expects a function argument", - }, - CORE_002 = { - code = "FLEXLOVE_CORE_002", - category = "SYS", - description = "Deferred callback execution failed", - suggestion = "Check the callback function for errors. Error details included in message.", - }, - CORE_003 = { - code = "FLEXLOVE_CORE_003", - category = "VAL", - description = "Invalid garbage collection strategy", - suggestion = "GC strategy must be one of: 'default', 'aggressive', 'conservative'", - }, - CORE_004 = { - code = "FLEXLOVE_CORE_004", - category = "SYS", - description = "Deferred method retry limit exceeded", - suggestion = "A deferred method has been retried too many times without succeeding. Check that preconditions are eventually met.", - }, - CORE_005 = { - code = "FLEXLOVE_CORE_005", - category = "VAL", - description = "Invalid deferred method", - suggestion = "The method name provided to _deferMethod does not exist on the element.", - }, - - -- Element Errors (ELEM_001 - ELEM_099) - ELEM_001 = { - code = "FLEXLOVE_ELEM_001", - category = "VAL", - description = "Invalid text size", - suggestion = "textSize must be greater than 0", - }, - ELEM_002 = { - code = "FLEXLOVE_ELEM_002", - category = "VAL", - description = "Invalid text size unit", - suggestion = "textSize unit must be one of: px, %, vw, vh, or presets: xs, sm, md, lg, xl, xxl, 2xl, 3xl, 4xl", - }, - ELEM_003 = { - code = "FLEXLOVE_ELEM_003", - category = "VAL", - description = "Invalid transition configuration", - suggestion = "setTransition() requires a table with transition properties", - }, - ELEM_004 = { - code = "FLEXLOVE_ELEM_004", - category = "VAL", - description = "Invalid transition duration", - suggestion = "Transition duration must be a non-negative number in seconds", - }, - ELEM_005 = { - code = "FLEXLOVE_ELEM_005", - category = "VAL", - description = "Invalid transition group", - suggestion = "setTransitionGroup() requires an array of property names", - }, - ELEM_006 = { - code = "FLEXLOVE_ELEM_006", - category = "VAL", - description = "Incompatible element configuration", - suggestion = "passwordMode and multiline cannot be used together. Multiline will be disabled.", - }, - ELEM_007 = { - code = "FLEXLOVE_ELEM_007", - category = "VAL", - description = "Invalid select frame configuration", - suggestion = "Pass a fully instantiated Element as selectParent.selectFrame. Create it unattached so the owning select can adopt it safely.", - }, - ELEM_008 = { - code = "FLEXLOVE_ELEM_008", - category = "VAL", - description = "Select frame was already parented before adoption", - suggestion = "Create the selectFrame without a parent, or explicitly accept that the select will reparent it during adoption.", - }, - ELEM_009 = { - code = "FLEXLOVE_ELEM_009", - category = "VAL", - description = "Managed select frame was reparented unexpectedly", - suggestion = "Avoid moving a managed selectFrame outside its owning select after adoption. Let the select own the frame lifecycle.", - }, - ELEM_010 = { - code = "FLEXLOVE_ELEM_010", - category = "VAL", - description = "Invalid display property value", - suggestion = "The display property accepts only boolean values (true/false). Pass `true` to show the element or `false` to hide it from layout, rendering, and hit testing.", - }, - - -- Module Loader Warnings (MOD_001 - MOD_099) - MOD_001 = { - code = "FLEXLOVE_MOD_001", - category = "RES", - description = "Optional module not found", - suggestion = "Using stub implementation for optional module. This is expected if the module is not required.", - }, - - -- Utility Errors (UTIL_001 - UTIL_099) - UTIL_001 = { - code = "FLEXLOVE_UTIL_001", - category = "VAL", - description = "Text truncation warning", - suggestion = "Text was truncated to fit within the maximum allowed length", - }, - - -- Image/Rendering Errors (IMG_001 - IMG_099) - IMG_001 = { - code = "FLEXLOVE_IMG_001", - category = "REN", - description = "Stencil buffer not available", - suggestion = "Cannot apply corner radius to image without stencil buffer support. Check graphics capabilities.", - }, - - -- Navigation Errors (NAV_001 - NAV_099) - NAV_001 = { - code = "FLEXLOVE_NAV_001", - category = "EVT", - description = "Element focus callback error", - suggestion = "Check the onFocus callback function for errors. Error details included in message.", - }, - NAV_002 = { - code = "FLEXLOVE_NAV_002", - category = "EVT", - description = "Element activation callback error", - suggestion = "Check the onEvent callback function for errors. Error details included in message.", - }, - NAV_003 = { - code = "FLEXLOVE_NAV_003", - category = "EVT", - description = "Element dismiss callback error", - suggestion = "Check the onDismiss callback function for errors. Error details included in message.", - }, - }, -} - ---- Get error information by code ---- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001") ---- @return table? errorInfo Error information or nil if not found -function ErrorCodes.get(code) - -- Handle both short and full format - local shortCode = code:gsub("^FLEXLOVE_", "") - return ErrorCodes.codes[shortCode] -end - ---- Get human-readable description for error code ---- @param code string Error code ---- @return string description Error description -function ErrorCodes.describe(code) - local info = ErrorCodes.get(code) - if info then - return info.description - end - return "Unknown error code: " .. code -end - ---- Search error codes by keyword ---- @param keyword string Keyword to search for ---- @return table codes Matching error codes -function ErrorCodes.search(keyword) - keyword = keyword:lower() - local result = {} - for code, info in pairs(ErrorCodes.codes) do - local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower() - if searchText:find(keyword, 1, true) then - table.insert(result, { - code = code, - fullCode = info.code, - description = info.description, - suggestion = info.suggestion, - category = ErrorCodes.categories[info.category], - }) - end - end - return result -end - ---- Format error message with code ---- @param code string Error code ---- @param message string Error message ---- @return string formattedMessage Formatted error message with code -function ErrorCodes.formatMessage(code, message) - local info = ErrorCodes.get(code) - if info then - return string.format("[%s] %s", info.code, message) - end - return message -end - ---- Validate that all error codes are unique and properly formatted ---- @return boolean, string? Returns true if valid, or false with error message -function ErrorCodes.validate() - local seen = {} - local fullCodes = {} - - for code, info in pairs(ErrorCodes.codes) do - -- Check for duplicates - if seen[code] then - return false, "Duplicate error code: " .. code - end - seen[code] = true - - if fullCodes[info.code] then - return false, "Duplicate full error code: " .. info.code - end - fullCodes[info.code] = true - - -- Check format - if not code:match("^[A-Z]+_[0-9]+$") then - return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)" - end - - -- Check full code format - local expectedFullCode = "FLEXLOVE_" .. code - if info.code ~= expectedFullCode then - return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code - end - - -- Check required fields - if not info.description or info.description == "" then - return false, "Missing description for " .. code - end - if not info.suggestion or info.suggestion == "" then - return false, "Missing suggestion for " .. code - end - if not info.category or info.category == "" then - return false, "Missing category for " .. code - end - end - - return true, nil -end - ----@enum LOG_LEVEL -local LOG_LEVEL = { - CRITICAL = 1, - ERROR = 2, - WARNING = 3, - INFO = 4, - DEBUG = 5, -} - ----@enum LOG_TARGET -local LOG_TARGET = { - CONSOLE = "console", - FILE = "file", - BOTH = "both", - NONE = "none", -} - ----@class ErrorHandler ----@field errorCodes ErrorCodes ----@field includeStackTrace boolean -- Default: false ----@field logLevel LOG_LEVEL --Default: LOG_LEVEL.WARNING ----@field logTarget "console" | "file" | "both" ----@field logFile string ----@field maxLogSize number in bytes ----@field maxLogFiles number files to rotate ----@field enableRotation boolean see maxLogFiles ----@field _currentLogSize number private ----@field _logFileHandle file* private -local ErrorHandler = { - errorCodes = ErrorCodes, -} -ErrorHandler.__index = ErrorHandler - ----@type ErrorHandler|nil -local instance = nil - ----@param config { includeStackTrace?: boolean, logLevel?: LOG_LEVEL, logTarget?: "console" | "file" | "both", logFile?: string, maxLogSize?: number, maxLogFiles?: number, enableRotation?: boolean }|nil ----@return ErrorHandler -function ErrorHandler.init(config) - if instance == nil then - local self = setmetatable({}, ErrorHandler) - self.includeStackTrace = config and config.includeStackTrace or false - self.logLevel = config and config.logLevel or LOG_LEVEL.WARNING - self.logTarget = config and config.logTarget or LOG_TARGET.CONSOLE - self.logFile = config and config.logFile or "flexlove-errors.log" - self.maxLogSize = config and config.maxLogSize or 10 * 1024 * 1024 - self.maxLogFiles = config and config.maxLogFiles or 5 - self.enableRotation = config and config.enableRotation or true - self._currentLogSize = 0 - self._logFileHandle = nil - instance = self - end - return instance -end - ---- Get the singleton instance (lazily initializes if needed) ----@return ErrorHandler -function ErrorHandler.getInstance() - if instance == nil then - ErrorHandler.init() - end - return instance -end - ---- Get current timestamp with milliseconds ----@return string|osdate Formatted timestamp -function ErrorHandler:_getTimestamp() - local time = os.time() - local date = os.date("%Y-%m-%d %H:%M:%S", time) - -- Note: Lua doesn't have millisecond precision by default, so we approximate - return date -end - ---- Rotate log file if needed -function ErrorHandler:_rotateLogIfNeeded() - if not self.enableRotation then - return - end - if self._currentLogSize < self.maxLogSize then - return - end - - -- Close current log - if self._logFileHandle then - self._logFileHandle:close() - self._logFileHandle = nil - end - - -- Rotate existing logs - for i = self.maxLogFiles - 1, 1, -1 do - local oldName = self.logFile .. "." .. i - local newName = self.logFile .. "." .. (i + 1) - os.rename(oldName, newName) -- Will fail silently if file doesn't exist - end - - -- Move current log to .1 - os.rename(self.logFile, self.logFile .. ".1") - - -- Create new log file - self._logFileHandle = io.open(self.logFile, "a") - self._currentLogSize = 0 -end - ---- Escape string for JSON ----@param str string String to escape ----@return string Escaped string -function ErrorHandler:_escapeJson(str) - str = tostring(str) - str = str:gsub("\\", "\\\\") - str = str:gsub('"', '\\"') - str = str:gsub("\n", "\\n") - str = str:gsub("\r", "\\r") - str = str:gsub("\t", "\\t") - return str -end - ---- Format details as JSON object ----@param details table|nil Details object ----@return string JSON string -function ErrorHandler:_formatDetailsJson(details) - if not details or type(details) ~= "table" then - return "{}" - end - - local parts = {} - for key, value in pairs(details) do - local jsonKey = self:_escapeJson(tostring(key)) - local jsonValue = self:_escapeJson(tostring(value)) - table.insert(parts, string.format('"%s":"%s"', jsonKey, jsonValue)) - end - - return "{" .. table.concat(parts, ",") .. "}" -end - ---- Format details object as readable key-value pairs ----@param details table|nil Details object ----@return string Formatted details -function ErrorHandler:_formatDetails(details) - if not details or type(details) ~= "table" then - return "" - end - - local lines = {} - for key, value in pairs(details) do - local formattedKey = tostring(key):gsub("^%l", string.upper) - local formattedValue = tostring(value) - -- Truncate very long values - if #formattedValue > 100 then - formattedValue = formattedValue:sub(1, 97) .. "..." - end - table.insert(lines, string.format(" %s: %s", formattedKey, formattedValue)) - end - - if #lines > 0 then - return "\n\nDetails:\n" .. table.concat(lines, "\n") - end - return "" -end - ---- Extract and format stack trace ----@param level number Stack level to start from ----@return string Formatted stack trace -function ErrorHandler:_formatStackTrace(level) - if not self.includeStackTrace then - return "" - end - - local lines = {} - local currentLevel = level or 3 - - while true do - local info = debug.getinfo(currentLevel, "Sl") - if not info then - break - end - - -- Skip internal Lua files - if info.source:match("^@") and not info.source:match("loveStub") then - local source = info.source:sub(2) -- Remove @ prefix - local location = string.format("%s:%d", source, info.currentline) - table.insert(lines, " " .. location) - end - - currentLevel = currentLevel + 1 - if currentLevel > level + 10 then - break - end -- Limit depth - end - - if #lines > 0 then - return "\n\nStack trace:\n" .. table.concat(lines, "\n") - end - return "" -end - ---- Format an error or warning message using error code lookup ----@param module string The module name (e.g., "Element", "Units", "Theme") ----@param level string "Error" or "Warning" ----@param code string Error code (e.g., "VAL_001") ----@param details table|nil Optional details object ----@return string Formatted message -function ErrorHandler:_formatMessage(module, level, code, details) - local codeInfo = ErrorCodes.get(code) - - if not codeInfo then - return string.format("[FlexLove - %s] %s: Unknown error code: %s", module, level, code) - end - - -- Build formatted message - local parts = {} - - -- Header: [FlexLove - Module] Level [CODE]: Description - table.insert(parts, string.format("[FlexLove - %s] %s [%s]: %s", module, level, codeInfo.code, codeInfo.description)) - - -- Details section - if details and type(details) == "table" then - table.insert(parts, self:_formatDetails(details)) - end - - -- Suggestion section - if codeInfo.suggestion and codeInfo.suggestion ~= "" then - table.insert(parts, string.format("\n\nSuggestion: %s", codeInfo.suggestion)) - end - - return table.concat(parts, "") -end - ---- Write log entry to file and/or console ----@param level string Log level ----@param levelNum number Log level number ----@param module string Module name ----@param code string|nil Error code ----@param message string Message ----@param details table|nil Details ----@param suggestion string|nil Suggestion -function ErrorHandler:_writeLog(level, levelNum, module, code, message, details, suggestion) - -- Check if we should log this level - if not levelNum or not self.logLevel or levelNum > self.logLevel then - return - end - - local timestamp = self:_getTimestamp() - local logEntry - - local jsonParts = { - string.format('"timestamp":"%s"', self:_escapeJson(timestamp)), - string.format('"level":"%s"', level), - string.format('"module":"%s"', self:_escapeJson(module)), - string.format('"message":"%s"', self:_escapeJson(message)), - } - - if code then - table.insert(jsonParts, string.format('"code":"%s"', self:_escapeJson(code))) - end - - if details then - table.insert(jsonParts, string.format('"details":%s', self:_formatDetailsJson(details))) - end - - if suggestion then - table.insert(jsonParts, string.format('"suggestion":"%s"', self:_escapeJson(suggestion))) - end - - logEntry = "{" .. table.concat(jsonParts, ",") .. "}\n" - - if self.logTarget == "console" or self.logTarget == "both" then - io.write(logEntry) - io.flush() - end - - -- Write to file - if self.logTarget == "file" or self.logTarget == "both" then - -- Lazy file opening: open on first write - if not self._logFileHandle then - self._logFileHandle = io.open(self.logFile, "a") - if self._logFileHandle then - -- Get current file size - local currentPos = self._logFileHandle:seek("end") - self._currentLogSize = currentPos or 0 - end - end - - if self._logFileHandle then - self:_rotateLogIfNeeded() - - -- Reopen if rotation closed it - if not self._logFileHandle then - self._logFileHandle = io.open(self.logFile, "a") - end - - if self._logFileHandle then - self._logFileHandle:write(logEntry) - self._logFileHandle:flush() - self._currentLogSize = self._currentLogSize + #logEntry - end - end - end -end - ---- Throw a critical error (stops execution) ----@param module string The module name ----@param code string Error code (e.g., "VAL_001") ----@param details table|nil Optional details object -function ErrorHandler:error(module, code, details) - local formattedMessage = self:_formatMessage(module, "Error", code, details) - - local codeInfo = ErrorCodes.get(code) - local message = codeInfo and codeInfo.description or code - local suggestion = codeInfo and codeInfo.suggestion or nil - - -- Log the error - self:_writeLog("ERROR", LOG_LEVEL.ERROR, module, code, message, details, suggestion) - - if self.includeStackTrace then - formattedMessage = formattedMessage .. self:_formatStackTrace(3) - end - - error(formattedMessage, 2) -end - ---- Print a warning (non-critical, continues execution) ----@param module string The module name ----@param code string Warning code (e.g., "VAL_001") ----@param details table|nil Optional details object -function ErrorHandler:warn(module, code, details) - local codeInfo = ErrorCodes.get(code) - local message = codeInfo and codeInfo.description or code - local suggestion = codeInfo and codeInfo.suggestion or nil - - -- Log the warning - self:_writeLog("WARNING", LOG_LEVEL.WARNING, module, code, message, details, suggestion) -end - ---- Validate that a value is not nil ----@param module string The module name ----@param value any The value to check ----@param paramName string The parameter name ----@return boolean True if valid -function ErrorHandler:assertNotNil(module, value, paramName) - if value == nil then - self:error(module, "VAL_003", "Required parameter missing", { - parameter = paramName, - }) - return false - end - return true -end - ---- Validate that a value is of the expected type ----@param module string The module name ---- Warn if a value is deprecated ----@param module string The module name ----@param oldName string The deprecated name ----@param newName string The new name to use -function ErrorHandler:warnDeprecated(module, oldName, newName) - self:warn(module, string.format("'%s' is deprecated. Use '%s' instead", oldName, newName)) -end - -return ErrorHandler diff --git a/libs/flexlove/modules/EventHandler.lua b/libs/flexlove/modules/EventHandler.lua deleted file mode 100644 index 2631fc47..00000000 --- a/libs/flexlove/modules/EventHandler.lua +++ /dev/null @@ -1,843 +0,0 @@ ----@class EventHandler ----@field onEvent fun(element:Element, event:InputEvent)? ----@field onEventDeferred boolean? ----@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback ----@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred ----@field onGesture fun(element:Element, gesture:table)? -- Gesture callback ----@field onGestureDeferred boolean? -- Whether onGesture is deferred ----@field touchEnabled boolean -- Whether touch events are processed (default: true) ----@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false) ----@field _pressed table ----@field _lastClickTime number? ----@field _lastClickButton number? ----@field _clickCount number ----@field _dragStartX table ----@field _dragStartY table ----@field _lastMouseX table ----@field _lastMouseY table ----@field _touches table -- Multi-touch state per touch ID ----@field _touchStartPositions table -- Touch start positions ----@field _lastTouchPositions table -- Last touch positions for delta ----@field _touchHistory table -- Touch position history for gestures (last 5) ----@field _hovered boolean ----@field _scrollbarPressHandled boolean ----@field _InputEvent table ----@field _utils table ----@field _Performance Performance? Performance module dependency ----@field _ErrorHandler ErrorHandler -local EventHandler = {} -EventHandler.__index = EventHandler - ---- Initialize module with shared dependencies ----@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils} -function EventHandler.init(deps) - EventHandler._Performance = deps.Performance - EventHandler._ErrorHandler = deps.ErrorHandler - EventHandler._InputEvent = deps.InputEvent - EventHandler._utils = deps.utils - EventHandler._Context = deps.Context -end - ----@param config table Configuration options ----@return EventHandler -function EventHandler.new(config) - config = config or {} - local self = setmetatable({}, EventHandler) - - self.onEvent = config.onEvent - self.onEventDeferred = config.onEventDeferred - self.onTouchEvent = config.onTouchEvent - self.onTouchEventDeferred = config.onTouchEventDeferred or false - self.onGesture = config.onGesture - self.onGestureDeferred = config.onGestureDeferred or false - self.touchEnabled = config.touchEnabled ~= false -- Default true - self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false - - self._pressed = config._pressed or {} - - self._lastClickTime = config._lastClickTime - self._lastClickButton = config._lastClickButton - self._clickCount = config._clickCount or 0 - - -- FocusIndicator reference (set after initialization) - self._FocusIndicator = nil - - self._dragStartX = config._dragStartX or {} - self._dragStartY = config._dragStartY or {} - self._lastMouseX = config._lastMouseX or {} - self._lastMouseY = config._lastMouseY or {} - - -- Multi-touch tracking - self._touches = config._touches or {} - self._touchStartPositions = config._touchStartPositions or {} - self._lastTouchPositions = config._lastTouchPositions or {} - self._touchHistory = config._touchHistory or {} - - self._hovered = config._hovered or false - - self._scrollbarPressHandled = false - - return self -end - ---- Get state for persistence (for immediate mode) ----@return table State data -function EventHandler:getState() - return { - _pressed = self._pressed, - _lastClickTime = self._lastClickTime, - _lastClickButton = self._lastClickButton, - _clickCount = self._clickCount, - _dragStartX = self._dragStartX, - _dragStartY = self._dragStartY, - _lastMouseX = self._lastMouseX, - _lastMouseY = self._lastMouseY, - _touches = self._touches, - _touchStartPositions = self._touchStartPositions, - _lastTouchPositions = self._lastTouchPositions, - _touchHistory = self._touchHistory, - _hovered = self._hovered, - } -end - ---- Restore state from persistence (for immediate mode) ----@param state table State data -function EventHandler:setState(state) - if not state then - return - end - - self._pressed = state._pressed or {} - self._lastClickTime = state._lastClickTime - self._lastClickButton = state._lastClickButton - self._clickCount = state._clickCount or 0 - self._dragStartX = state._dragStartX or {} - self._dragStartY = state._dragStartY or {} - self._lastMouseX = state._lastMouseX or {} - self._lastMouseY = state._lastMouseY or {} - self._touches = state._touches or {} - self._touchStartPositions = state._touchStartPositions or {} - self._lastTouchPositions = state._lastTouchPositions or {} - self._touchHistory = state._touchHistory or {} - self._hovered = state._hovered or false -end - ---- Process mouse button events in the update cycle ----@param element Element The parent element ----@param mx number Mouse X position ----@param my number Mouse Y position ----@param isHovering boolean Whether mouse is over element ----@param isActiveElement boolean Whether this is the top element at mouse position -function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement) - -- Start performance timing - -- Performance accessed via EventHandler._Performance - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:startTimer("event_mouse") - end - - -- Check if currently dragging (allows drag continuation even if occluded) - 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 - - -- Check if any button is currently pressed (tracked state) - local hasTrackedPress = false - for _, button in ipairs({ 1, 2, 3 }) do - if self._pressed[button] then - hasTrackedPress = true - break - end - end - - -- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press - -- Read onEvent from element (source of truth), fallback to handler cache for backwards compat - local canProcessEvents = ( - element.onEvent - or self.onEvent - or element.editable - or element._selectState - or element.selectOption - ) - and element.visibility ~= "hidden" - and not element.disabled - and (isActiveElement or isDragging or hasTrackedPress) - - if not canProcessEvents then - -- If not hovering and no buttons are physically pressed, reset all pressed states - -- This ensures the pressed state is cleared when mouse leaves without button held - if not isHovering and not isDragging then - for _, button in ipairs({ 1, 2, 3 }) do - if self._pressed[button] and not love.mouse.isDown(button) then - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - end - end - end - - -- Track hover state changes even when events can't be processed - -- Fire synthetic unhover when element becomes disabled while hovered - if element.disabled and self._hovered then - self._hovered = false - if element.onEvent or self.onEvent then - local modifiers = EventHandler._utils.getModifiers() - local unhoverEvent = EventHandler._InputEvent.new({ - type = "unhover", - button = 0, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 0, - }) - self:_invokeCallback(element, unhoverEvent) - end - elseif self._hovered and not isHovering then - self._hovered = false - if element.onEvent or self.onEvent then - local modifiers = EventHandler._utils.getModifiers() - local unhoverEvent = EventHandler._InputEvent.new({ - type = "unhover", - button = 0, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 0, - }) - self:_invokeCallback(element, unhoverEvent) - end - end - - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:stopTimer("event_mouse") - end - return - end - - -- Track hover state changes and fire hover/unhover events BEFORE button processing - -- This ensures hover fires before press when mouse first enters element - local wasHovered = self._hovered - local isHoveringAndActive = isHovering and isActiveElement - - if isHoveringAndActive and not wasHovered then - -- Just started hovering - fire hover event - self._hovered = true - local modifiers = EventHandler._utils.getModifiers() - local hoverEvent = EventHandler._InputEvent.new({ - type = "hover", - button = 0, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 0, - }) - self:_invokeCallback(element, hoverEvent) - elseif not isHoveringAndActive and wasHovered then - -- Just stopped hovering - fire unhover event - self._hovered = false - local modifiers = EventHandler._utils.getModifiers() - local unhoverEvent = EventHandler._InputEvent.new({ - type = "unhover", - button = 0, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 0, - }) - self:_invokeCallback(element, unhoverEvent) - end - - -- Process all three mouse buttons - local buttons = { 1, 2, 3 } -- left, right, middle - - for _, button in ipairs(buttons) do - -- Check if this button was tracked as pressed - local wasPressed = self._pressed[button] - local isPhysicallyPressed = love.mouse.isDown(button) - - if isHovering or isDragging or wasPressed then - if isPhysicallyPressed then - -- Button is pressed down - if not wasPressed then - -- Just pressed - fire press event (only if hovering) - if isHovering then - self:_handleMousePress(element, mx, my, button) - end - else - -- Button is still pressed - check for drag - self:_handleMouseDrag(element, mx, my, button, isHovering) - end - elseif wasPressed then - -- Button was just released - -- Only fire click and release events if mouse is still hovering AND element is active - -- (not occluded by another element) - if isHovering and isActiveElement then - self:_handleMouseRelease(element, mx, my, button) - else - -- Mouse left before release OR element is occluded - just clear the pressed state without firing events - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - end - end - end - end - - -- After processing events, reset pressed states for buttons that are no longer held - -- This handles the case where mouse leaves while button is held, then released - if not isHovering and not isDragging then - for _, button in ipairs({ 1, 2, 3 }) do - if self._pressed[button] and not love.mouse.isDown(button) then - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - end - end - end - - -- Stop performance timing - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:stopTimer("event_mouse") - end -end - ---- Handle mouse button press ----@param element Element The parent element ----@param mx number Mouse X position ----@param my number Mouse Y position ----@param button number Mouse button (1=left, 2=right, 3=middle) -function EventHandler:_handleMousePress(element, mx, my, button) - -- Check if press is on scrollbar first (skip if already handled) - if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then - if element:_handleScrollbarPress(mx, my, button) then - -- Scrollbar consumed the event, mark as pressed to prevent onEvent - self._pressed[button] = true - self._scrollbarPressHandled = true - return - end - end - - -- Fire press event - local modifiers = EventHandler._utils.getModifiers() - local pressEvent = EventHandler._InputEvent.new({ - type = "press", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 1, - }) - self:_invokeCallback(element, pressEvent) - - self._pressed[button] = true - - -- On left click, set keyboard focus to any focusable element (not just editable). - -- Clear the focus indicator since mouse navigation doesn't use it. - local isFocusable - if type(element.isFocusable) == "function" then - isFocusable = element:isFocusable() - else - isFocusable = (element.editable == true) - or (type(element.onEvent) == "function") - or element._selectState ~= nil - or element.selectOption ~= nil - end - - if button == 1 and EventHandler._Context and isFocusable then - EventHandler._Context.setFocused(element) - -- Hide focus indicator - it's only for keyboard navigation - if EventHandler._FocusIndicator then - EventHandler._FocusIndicator.setFocused(nil) - end - end - -- Set mouse down position for text selection on left click - if button == 1 and element._textEditor then - element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my) - element._textDragOccurred = false -- Reset drag flag on press - end - - -- Record drag start position per button - self._dragStartX[button] = mx - self._dragStartY[button] = my - self._lastMouseX[button] = mx - self._lastMouseY[button] = my -end - ---- Handle mouse drag (while button is pressed and mouse moves) ----@param element Element The parent element ----@param mx number Mouse X position ----@param my number Mouse Y position ----@param button number Mouse button ----@param isHovering boolean Whether mouse is over element -function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering) - local lastX = self._lastMouseX[button] or mx - local lastY = self._lastMouseY[button] or my - - if lastX ~= mx or lastY ~= my then - -- Handle scrollbar drag if scrollbar was pressed - if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then - element:_handleScrollbarDrag(mx, my) - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - return -- Don't process other drag events while dragging scrollbar - end - - -- Mouse has moved - fire drag event only if still hovering - if isHovering then - local modifiers = EventHandler._utils.getModifiers() - local dx = mx - self._dragStartX[button] - local dy = my - self._dragStartY[button] - - local dragEvent = EventHandler._InputEvent.new({ - type = "drag", - button = button, - x = mx, - y = my, - dx = dx, - dy = dy, - modifiers = modifiers, - clickCount = 1, - }) - self:_invokeCallback(element, dragEvent) - end - - -- Handle text selection drag for editable elements - if button == 1 and element.editable and element._focused and element._handleTextDrag then - element:_handleTextDrag(mx, my) - end - - -- Update last known position for this button - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - end -end - ---- Handle mouse button release ----@param mx number Mouse X position ----@param my number Mouse Y position ----@param button number Mouse button -function EventHandler:_handleMouseRelease(element, mx, my, button) - local currentTime = love.timer.getTime() - local modifiers = EventHandler._utils.getModifiers() - - -- Handle scrollbar release if scrollbar was pressed - if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then - element:_handleScrollbarRelease(button) - self._scrollbarPressHandled = false -- Reset flag - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - return -- Don't process click events for scrollbar release - end - - -- Determine click count (double-click detection) - local clickCount - local doubleClickThreshold = 0.3 -- 300ms for double-click - - if - self._lastClickTime - and self._lastClickButton == button - and (currentTime - self._lastClickTime) < doubleClickThreshold - then - clickCount = self._clickCount + 1 - else - clickCount = 1 - end - - self._clickCount = clickCount - self._lastClickTime = currentTime - self._lastClickButton = button - - -- Determine event type based on button - local eventType = "click" - if button == 2 then - eventType = "rightclick" - elseif button == 3 then - eventType = "middleclick" - end - - -- Fire click event - local clickEvent = EventHandler._InputEvent.new({ - type = eventType, - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - self:_invokeCallback(element, clickEvent) - - self._pressed[button] = false - - -- Clean up drag tracking - self._dragStartX[button] = nil - self._dragStartY[button] = nil - - -- Clean up text selection drag tracking - if button == 1 then - element._mouseDownPosition = nil - end - - -- Focus editable elements on left click - if button == 1 and element.editable then - -- Only focus if not already focused (to avoid moving cursor to end) - local wasFocused = element:isFocused() - if not wasFocused then - element:focus() - end - - -- Handle text click for cursor positioning and word selection - -- Only process click if no text drag occurred (to preserve drag selection) - if element._handleTextClick and not element._textDragOccurred then - element:_handleTextClick(mx, my, clickCount) - end - - -- Reset drag flag after release - element._textDragOccurred = false - end - - -- Fire release event - local releaseEvent = EventHandler._InputEvent.new({ - type = "release", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - self:_invokeCallback(element, releaseEvent) - - if button == 1 and element._handleSelectRelease then - element:_handleSelectRelease() - end -end - ---- Process touch events in the update cycle ----@param element Element The parent element -function EventHandler:processTouchEvents(element) - -- Start performance timing - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:startTimer("event_touch") - end - - -- Check if element can process events - local canProcessEvents = ( - element.onEvent - or self.onEvent - or element.onTouchEvent - or self.onTouchEvent - or element.editable - ) - and not element.disabled - and self.touchEnabled - - if not canProcessEvents then - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:stopTimer("event_touch") - end - return - end - - 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) - - -- Get current active touches from LÖVE - local activeTouches = {} - local touches = love.touch.getTouches() - for _, id in ipairs(touches) do - activeTouches[tostring(id)] = true - end - - -- Count active tracked touches for multi-touch filtering - local trackedTouchCount = 0 - for _ in pairs(self._touches) do - trackedTouchCount = trackedTouchCount + 1 - end - - -- Process active touches - for _, id in ipairs(touches) do - local touchId = tostring(id) - local tx, ty = love.touch.getPosition(id) - local pressure = 1.0 -- LÖVE doesn't provide pressure by default - - -- Check if touch is within element bounds - local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh - - if isInside then - if not self._touches[touchId] then - -- Multi-touch filtering: reject new touches when multiTouchEnabled=false - -- and we already have an active touch - if self.multiTouchEnabled or trackedTouchCount == 0 then - -- New touch began - self:_handleTouchBegan(element, touchId, tx, ty, pressure) - trackedTouchCount = trackedTouchCount + 1 - end - else - -- Touch moved - self:_handleTouchMoved(element, touchId, tx, ty, pressure) - end - elseif self._touches[touchId] then - -- Touch moved outside or ended - if activeTouches[touchId] then - -- Still active but outside - fire moved event - self:_handleTouchMoved(element, touchId, tx, ty, pressure) - else - -- Touch ended - self:_handleTouchEnded(element, touchId, tx, ty, pressure) - end - end - end - - -- Check for ended touches (touches that were tracked but are no longer active) - for touchId, _ in pairs(self._touches) do - if not activeTouches[touchId] then - -- Touch ended or cancelled - local lastPos = self._lastTouchPositions[touchId] - if lastPos then - self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0) - else - -- Cleanup orphaned touch - self:_cleanupTouch(touchId) - end - end - end - - -- Stop performance timing - if EventHandler._Performance and EventHandler._Performance.enabled then - EventHandler._Performance:stopTimer("event_touch") - end -end - ---- Handle touch began event ----@param element Element The parent element ----@param touchId string Touch identifier ----@param x number Touch X position ----@param y number Touch Y position ----@param pressure number Touch pressure (0-1) -function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure) - -- Create touch state - self._touches[touchId] = { - x = x, - y = y, - pressure = pressure, - timestamp = love.timer.getTime(), - phase = "began", - } - - -- Record start position - self._touchStartPositions[touchId] = { x = x, y = y } - self._lastTouchPositions[touchId] = { x = x, y = y } - - -- Initialize touch history - self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } } - - -- Create and fire touch press event - local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure) - touchEvent.type = "touchpress" - touchEvent.dx = 0 - touchEvent.dy = 0 - self:_invokeCallback(element, touchEvent) - self:_invokeTouchCallback(element, touchEvent) -end - ---- Handle touch moved event ----@param element Element The parent element ----@param touchId string Touch identifier ----@param x number Touch X position ----@param y number Touch Y position ----@param pressure number Touch pressure (0-1) -function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure) - local touchState = self._touches[touchId] - - if not touchState then - -- Touch not tracked, ignore - return - end - - local lastPos = self._lastTouchPositions[touchId] - if not lastPos or lastPos.x ~= x or lastPos.y ~= y then - -- Touch position changed - local startPos = self._touchStartPositions[touchId] - local dx = x - startPos.x - local dy = y - startPos.y - - -- Update touch state - touchState.x = x - touchState.y = y - touchState.pressure = pressure - touchState.phase = "moved" - - -- Update last position - self._lastTouchPositions[touchId] = { x = x, y = y } - - -- Add to touch history (keep last 5 positions) - local history = self._touchHistory[touchId] or {} - table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() }) - if #history > 5 then - table.remove(history, 1) - end - self._touchHistory[touchId] = history - - -- Create and fire touch move event - local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure) - touchEvent.type = "touchmove" - touchEvent.dx = dx - touchEvent.dy = dy - self:_invokeCallback(element, touchEvent) - self:_invokeTouchCallback(element, touchEvent) - end -end - ---- Handle touch ended event ----@param element Element The parent element ----@param touchId string Touch identifier ----@param x number Touch X position ----@param y number Touch Y position ----@param pressure number Touch pressure (0-1) -function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure) - local touchState = self._touches[touchId] - - if not touchState then - -- Touch not tracked, ignore - return - end - - local startPos = self._touchStartPositions[touchId] - local dx = x - startPos.x - local dy = y - startPos.y - - -- Create and fire touch release event - local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure) - touchEvent.type = "touchrelease" - touchEvent.dx = dx - touchEvent.dy = dy - self:_invokeCallback(element, touchEvent) - self:_invokeTouchCallback(element, touchEvent) - - -- Cleanup touch state - self:_cleanupTouch(touchId) -end - ---- Cleanup touch state ----@param touchId string Touch ID -function EventHandler:_cleanupTouch(touchId) - self._touches[touchId] = nil - self._touchStartPositions[touchId] = nil - self._lastTouchPositions[touchId] = nil - self._touchHistory[touchId] = nil -end - ---- Get active touches on this element ----@return table Active touches -function EventHandler:getActiveTouches() - return self._touches -end - ---- Reset scrollbar press flag (called each frame) -function EventHandler:resetScrollbarPressFlag() - self._scrollbarPressHandled = false -end - ---- Check if any mouse button is pressed ----@return boolean True if any button is pressed -function EventHandler:isAnyButtonPressed() - for _, pressed in pairs(self._pressed) do - if pressed then - return true - end - end - return false -end - ---- Check if a specific button is pressed ----@param button number Mouse button (1=left, 2=right, 3=middle) ----@return boolean True if button is pressed -function EventHandler:isButtonPressed(button) - return self._pressed[button] == true -end - ---- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true ----@param element Element The element that triggered the event ----@param event InputEvent The event data -function EventHandler:_invokeCallback(element, event) - -- Read onEvent from element (source of truth), fallback to handler cache for backwards compat - local callback = element.onEvent or self.onEvent - if not callback then - return - end - - if self.onEventDeferred then - -- Get FlexLove module to defer the callback - local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] - if FlexLove and FlexLove.deferCallback then - FlexLove.deferCallback(function() - callback(element, event) - end) - else - EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { - eventType = event.type, - }) - end - else - callback(element, event) - end -end - ---- Invoke the onTouchEvent callback, optionally deferring it ----@param element Element The element that triggered the event ----@param event InputEvent The touch event data -function EventHandler:_invokeTouchCallback(element, event) - -- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat - local callback = element.onTouchEvent or self.onTouchEvent - if not callback then - return - end - - if self.onTouchEventDeferred then - local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] - if FlexLove and FlexLove.deferCallback then - FlexLove.deferCallback(function() - callback(element, event) - end) - else - EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { - eventType = event.type, - }) - end - else - callback(element, event) - end -end - ---- Invoke the onGesture callback, optionally deferring it ----@param element Element The element that triggered the event ----@param gesture table The gesture data from GestureRecognizer -function EventHandler:_invokeGestureCallback(element, gesture) - -- Read onGesture from element (source of truth), fallback to handler cache for backwards compat - local callback = element.onGesture or self.onGesture - if not callback then - return - end - - if self.onGestureDeferred then - local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"] - if FlexLove and FlexLove.deferCallback then - FlexLove.deferCallback(function() - callback(element, gesture) - end) - else - EventHandler._ErrorHandler:error("EventHandler", "SYS_003", { - gestureType = gesture.type, - }) - end - else - callback(element, gesture) - end -end - -return EventHandler diff --git a/libs/flexlove/modules/FocusIndicator.lua b/libs/flexlove/modules/FocusIndicator.lua deleted file mode 100644 index ebbac8ad..00000000 --- a/libs/flexlove/modules/FocusIndicator.lua +++ /dev/null @@ -1,232 +0,0 @@ -local packageName = ... or "FocusIndicator" -local modulePath = packageName:match("(.-)[^%.]+$") - -local function req(name) - return require(modulePath .. name) -end - -local FocusIndicator = {} - ---- Configuration ----@type KeyboardNavigationFocusIndicatorConfig -FocusIndicator.config = { - enabled = true, - - --- Custom draw function to override default rendering - ---@type function|nil - --- Called with: element, bounds, style - return true to skip default drawing - draw = nil, - - -- Appearance - color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity - lineWidth = 2, - inset = -3, -- Negative value extends beyond element - borderRadius = 4, - - -- Animation - animationDuration = 0.15, -- Seconds for focus animation - pulseEnabled = false, -- Enable pulsing animation - pulseDuration = 1.0, -- Seconds per pulse cycle - pulseScaleMin = 0.95, -- Minimum scale during pulse - pulseScaleMax = 1.05, -- Maximum scale during pulse -} - ---- State -FocusIndicator._focusedElement = nil -FocusIndicator._animationProgress = 0 -FocusIndicator._pulsePhase = 0 -FocusIndicator._hidden = true -FocusIndicator._deps = nil - ---- Initialize FocusIndicator module ----@param deps table Dependencies table containing Context and Color modules ----@field deps.Context table Context module for getting focused element ----@field deps.Color table Color module for color manipulation -function FocusIndicator.init(deps) - FocusIndicator._deps = deps - FocusIndicator._Context = deps.Context - FocusIndicator._Color = deps.Color -end - ---- Update animation state for entrance and pulse effects ----@param dt number Delta time in seconds since last frame -function FocusIndicator:update(dt) - if not FocusIndicator.config.enabled then - return - end - - -- Update focus entrance animation - if FocusIndicator._animationProgress < 1 then - FocusIndicator._animationProgress = - math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration)) - end - - -- Update pulse animation - if FocusIndicator.config.pulseEnabled then - FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration - end -end - ---- Set the focused element to render indicator around ----@param element Element? The element to show focus indicator around, or nil to hide -function FocusIndicator.setFocused(element) - FocusIndicator._focusedElement = element - FocusIndicator._hidden = element == nil - -- Reset animation when focus changes - if element then - FocusIndicator._animationProgress = 0 - end -end - ---- Get the current scale factor for animations ---- Combines entrance scale (0.8 to 1.0) with optional pulse scale ----@return number Scale factor (typically 0.8-1.05 range) -function FocusIndicator:getScale() - local scale = 1 - - -- Apply entrance animation (scale up from 0.8) - local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress) - scale = scale * entranceScale - - -- Apply pulse animation - if FocusIndicator.config.pulseEnabled then - local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration - -- Smooth sine wave pulse - local pulseScale = FocusIndicator.config.pulseScaleMin - + (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin) - * (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress)) - scale = scale * pulseScale - end - - return scale -end - ---- Get the current opacity for the indicator ---- Applies entrance animation fade-in to the configured alpha ----@return number Alpha value (0-1 range) -function FocusIndicator:getOpacity() - -- Fade in on focus - return FocusIndicator.config.color[4] * FocusIndicator._animationProgress -end - ---- Draw the focus indicator around the focused element ---- Renders a rounded rectangle border, or calls custom draw function if configured ---- Should be called from within love.draw() after all elements are drawn -function FocusIndicator:draw() - if not FocusIndicator.config.enabled then - return - end - - if FocusIndicator._hidden then - return - end - - -- In immediate mode the stored element reference is stale (recreated every frame). - -- Always resolve through Context so we get the live object with up-to-date positions. - local element - if FocusIndicator._Context then - element = FocusIndicator._Context.getFocused() - else - element = FocusIndicator._focusedElement - end - - if not element then - return - end - - -- Get element dimensions (use border-box size which includes padding) - local x = element.x or 0 - local y = element.y or 0 - local w = element._borderBoxWidth - or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0)) - local h = element._borderBoxHeight - or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0)) - - if w == 0 or h == 0 then - return - end - - -- Calculate indicator dimensions with inset and scale - local inset = FocusIndicator.config.inset - local scale = self:getScale() - - local indicatorX = x + inset - local indicatorY = y + inset - local indicatorW = w - 2 * inset - local indicatorH = h - 2 * inset - - -- Center the scale around the element - local offsetX = (indicatorW * (1 - scale)) / 2 - local offsetY = (indicatorH * (1 - scale)) / 2 - - indicatorX = indicatorX + offsetX - indicatorY = indicatorY + offsetY - indicatorW = indicatorW * scale - indicatorH = indicatorH * scale - - -- Get color with animated opacity - local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3] - local a = self:getOpacity() - - -- Build style table for custom draw callback - local bounds = { - x = indicatorX, - y = indicatorY, - width = indicatorW, - height = indicatorH, - } - - local style = { - color = { r = r, g = g, b = b, a = a }, - lineWidth = FocusIndicator.config.lineWidth, - borderRadius = FocusIndicator.config.borderRadius, - scale = scale, - opacity = a, - } - - -- Check for custom draw callback - if FocusIndicator.config.draw then - local skipDefault = FocusIndicator.config.draw(element, bounds, style) - if skipDefault then - return - end - end - - -- Save current love.graphics state - local prevBlend, prevAlphaMode = love.graphics.getBlendMode() - local prevR, prevG, prevB, prevA = love.graphics.getColor() - local prevLineWidth = love.graphics.getLineWidth() - - -- Set blend mode for transparency - love.graphics.setBlendMode("alpha") - - -- Draw rounded rectangle border - love.graphics.setColor(r, g, b, a) - love.graphics.setLineWidth(FocusIndicator.config.lineWidth) - - -- Draw the rounded rectangle border - local borderRadius = FocusIndicator.config.borderRadius - love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius) - - -- Restore love.graphics state - love.graphics.setBlendMode(prevBlend, prevAlphaMode) - love.graphics.setColor(prevR, prevG, prevB, prevA) - love.graphics.setLineWidth(prevLineWidth) -end - ---- Set the indicator color ----@param r number Red component (0-1 range) ----@param g number Green component (0-1 range) ----@param b number Blue component (0-1 range) ----@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted -function FocusIndicator.setColor(r, g, b, a) - FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] } -end - ---- Set the stroke width for the indicator border ----@param width number Line width in pixels -function FocusIndicator.setLineWidth(width) - FocusIndicator.config.lineWidth = width -end - -return FocusIndicator diff --git a/libs/flexlove/modules/FontCache.lua b/libs/flexlove/modules/FontCache.lua deleted file mode 100644 index 99e82d17..00000000 --- a/libs/flexlove/modules/FontCache.lua +++ /dev/null @@ -1,274 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Font cache with LRU eviction, font resolution, and cache management. --- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid --- a cross-import into utils (utils re-exports the cache via aliases). - --- Font cache with LRU eviction -local FONT_CACHE = {} -local FONT_CACHE_MAX_SIZE = 50 -local FONT_CACHE_STATS = { - hits = 0, - misses = 0, - evictions = 0, - size = 0, -} - -local ErrorHandler = nil -local resolveImagePath = nil - ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function } -local function init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - resolveImagePath = deps.resolveImagePath - end -end - --- LRU tracking: each entry has {font, lastUsed, accessCount} -local function updateCacheAccess(cacheKey) - local entry = FONT_CACHE[cacheKey] - if entry then - entry.lastUsed = love.timer.getTime() - entry.accessCount = entry.accessCount + 1 - end -end - -local function evictLRU() - local oldestKey = nil - local oldestTime = math.huge - - for key, entry in pairs(FONT_CACHE) do - -- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed) - if type(entry) == "table" and entry.lastUsed then - if entry.lastUsed < oldestTime then - oldestTime = entry.lastUsed - oldestKey = key - end - end - end - - if oldestKey then - FONT_CACHE[oldestKey] = nil - FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1 - FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1 - end -end - ---- Create or get a font from cache ----@param size number ----@param fontPath string? ----@return love.Font -function FONT_CACHE.get(size, fontPath) - -- Bucket font sizes for better cache reuse (reduces unique cache entries) - -- Small sizes (< 20): round to nearest 2 - -- Medium sizes (20-40): round to nearest 4 - -- Large sizes (> 40): round to nearest 8 - if size < 20 then - size = math.floor((size + 1) / 2) * 2 - elseif size < 40 then - size = math.floor((size + 2) / 4) * 4 - else - size = math.floor((size + 4) / 8) * 8 - end - - local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) - - if FONT_CACHE[cacheKey] then - -- Cache hit - FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1 - updateCacheAccess(cacheKey) - return FONT_CACHE[cacheKey].font - end - - -- Cache miss - FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 - - local font - if fontPath then - local resolvedPath = resolveImagePath(fontPath) - local success, result = pcall(love.graphics.newFont, resolvedPath, size) - if success then - font = result - else - if ErrorHandler then - ErrorHandler:warn("utils", "RES_004", { - resourceType = "font", - path = fontPath, - }) - end - font = love.graphics.newFont(size) - end - else - font = love.graphics.newFont(size) - end - - -- Per-glyph fallback so a non-Latin UI string is not drawn as tofu. - -- pcall'd require: FlexLove is vendored and must still load standalone. - local okUi, UiFont = pcall(require, "src.render.UiFont") - if okUi and UiFont then UiFont.attach(font, size) end - - -- Add to cache with LRU metadata - FONT_CACHE[cacheKey] = { - font = font, - lastUsed = love.timer.getTime(), - accessCount = 1, - } - FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 - - -- Evict if cache is full - if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then - evictLRU() - end - - return font -end - ---- Get font for text size (cached) ----@param textSize number? ----@param fontPath string? ----@return love.Font -function FONT_CACHE.getFont(textSize, fontPath) - if textSize then - return FONT_CACHE.get(textSize, fontPath) - else - return love.graphics.getFont() - end -end - --- Font resolution utilities - ---- Resolve font path from fontFamily and theme ----@param fontFamily string? Font family name or direct path ----@param themeComponent string? Theme component name ----@param themeManager table? ThemeManager instance ----@return string? Resolved font path or nil -local function resolveFontPath(fontFamily, themeComponent, themeManager) - if fontFamily then - -- Check if fontFamily is a theme font name - local themeToUse = themeManager and themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then - return themeToUse.fonts[fontFamily] - else - -- Treat as direct path to font file - return fontFamily - end - elseif themeComponent and themeManager then - -- If using themeComponent but no fontFamily specified, check for default font in theme - return themeManager:getDefaultFontFamily() - end - return nil -end - ---- Get font for element (resolves from theme or fontFamily) ----@param textSize number? Text size in pixels ----@param fontFamily string? Font family name or direct path ----@param themeComponent string? Theme component name ----@param themeManager table? ThemeManager instance ----@return love.Font -local function getFont(textSize, fontFamily, themeComponent, themeManager) - local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager) - return FONT_CACHE.getFont(textSize, fontPath) -end - --- Font cache management - ---- Get font cache statistics ----@return table stats {hits, misses, evictions, size, hitRate} -local function getFontCacheStats() - local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses - local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0 - return { - hits = FONT_CACHE_STATS.hits, - misses = FONT_CACHE_STATS.misses, - evictions = FONT_CACHE_STATS.evictions, - size = FONT_CACHE_STATS.size, - hitRate = hitRate, - } -end - ---- Set maximum font cache size ----@param maxSize number Maximum number of fonts to cache -local function setFontCacheSize(maxSize) - FONT_CACHE_MAX_SIZE = math.max(1, maxSize) - - -- Evict entries if cache is now over limit - while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do - evictLRU() - end -end - ---- Clear font cache -local function clearFontCache() - -- Clear cache entries but preserve methods (get, getFont) - for key, entry in pairs(FONT_CACHE) do - if type(entry) == "table" and entry.lastUsed then - FONT_CACHE[key] = nil - end - end - FONT_CACHE_STATS.size = 0 - FONT_CACHE_STATS.evictions = 0 -end - ---- Preload font at multiple sizes ----@param fontPath string? Path to font file (nil for default font) ----@param sizes table Array of font sizes to preload -local function preloadFont(fontPath, sizes) - for _, size in ipairs(sizes) do - -- Round size to reduce cache entries - size = math.floor(size + 0.5) - - local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) - - if not FONT_CACHE[cacheKey] then - local font - if fontPath then - local resolvedPath = resolveImagePath(fontPath) - local success, result = pcall(love.graphics.newFont, resolvedPath, size) - if success then - font = result - else - font = love.graphics.newFont(size) - end - else - font = love.graphics.newFont(size) - end - - FONT_CACHE[cacheKey] = { - font = font, - lastUsed = love.timer.getTime(), - accessCount = 1, - } - FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 - FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 - - -- Evict if cache is full - if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then - evictLRU() - end - end - end -end - ---- Reset font cache statistics -local function resetFontCacheStats() - FONT_CACHE_STATS.hits = 0 - FONT_CACHE_STATS.misses = 0 - FONT_CACHE_STATS.evictions = 0 -end - -return { - FONT_CACHE = FONT_CACHE, - init = init, - resolveFontPath = resolveFontPath, - getFont = getFont, - getFontCacheStats = getFontCacheStats, - setFontCacheSize = setFontCacheSize, - clearFontCache = clearFontCache, - preloadFont = preloadFont, - resetFontCacheStats = resetFontCacheStats, -} diff --git a/libs/flexlove/modules/GestureRecognizer.lua b/libs/flexlove/modules/GestureRecognizer.lua deleted file mode 100644 index 5e324f09..00000000 --- a/libs/flexlove/modules/GestureRecognizer.lua +++ /dev/null @@ -1,583 +0,0 @@ ----@class GestureRecognizer ----@field _touches table -- Current touch states ----@field _gestureStates table -- Active gesture states ----@field _config table -- Gesture configuration (thresholds, etc.) ----@field _InputEvent table ----@field _utils table -local GestureRecognizer = {} -GestureRecognizer.__index = GestureRecognizer - --- Gesture types enum -local GestureType = { - TAP = "tap", - DOUBLE_TAP = "double_tap", - LONG_PRESS = "long_press", - SWIPE = "swipe", - PAN = "pan", - PINCH = "pinch", - ROTATE = "rotate", -} - --- Gesture states -local GestureState = { - POSSIBLE = "possible", - BEGAN = "began", - CHANGED = "changed", - ENDED = "ended", - CANCELLED = "cancelled", - FAILED = "failed", -} - --- Default configuration -local defaultConfig = { - -- Tap gesture - tapMaxDuration = 0.3, -- seconds - tapMaxMovement = 10, -- pixels - - -- Double-tap gesture - doubleTapInterval = 0.3, -- seconds between taps - - -- Long-press gesture - longPressMinDuration = 0.5, -- seconds - longPressMaxMovement = 10, -- pixels - - -- Swipe gesture - swipeMinDistance = 50, -- pixels - swipeMaxDuration = 0.2, -- seconds - swipeMinVelocity = 200, -- pixels per second - - -- Pan gesture - panMinMovement = 5, -- pixels to start pan - - -- Pinch gesture - pinchMinScaleChange = 0.1, -- 10% scale change - - -- Rotate gesture - rotateMinAngleChange = 5, -- degrees -} - ---- Create a new GestureRecognizer instance ----@param config table? Optional configuration options ----@param deps table Dependencies {InputEvent, utils} ----@return GestureRecognizer -function GestureRecognizer.new(config, deps) - config = config or {} - - local self = setmetatable({}, GestureRecognizer) - - self._InputEvent = deps.InputEvent - self._utils = deps.utils - - -- Merge configuration with defaults - self._config = {} - for key, value in pairs(defaultConfig) do - self._config[key] = config[key] or value - end - - self._touches = {} - self._gestureStates = { - tap = nil, - doubleTap = { lastTapTime = 0, tapCount = 0 }, - longPress = {}, - swipe = {}, - pan = {}, - pinch = {}, - rotate = {}, - } - - return self -end - ---- Update gesture recognizer with touch event ----@param event InputEvent Touch event -function GestureRecognizer:processTouchEvent(event) - if not event.touchId then - return nil - end - - local touchId = event.touchId - local gestures = {} - - -- Update touch state - if event.type == "touchpress" then - self._touches[touchId] = { - startX = event.x, - startY = event.y, - x = event.x, - y = event.y, - startTime = event.timestamp, - lastTime = event.timestamp, - phase = "began", - } - - -- Initialize gesture detection - self:_detectTapBegan(touchId, event) - self:_detectLongPressBegan(touchId, event) - elseif event.type == "touchmove" then - local touch = self._touches[touchId] - if touch then - touch.x = event.x - touch.y = event.y - touch.lastTime = event.timestamp - touch.phase = "moved" - - -- Update gesture detection - local panGesture = self:_detectPan(touchId, event) - if panGesture then - table.insert(gestures, panGesture) - end - local swipeGesture = self:_detectSwipe(touchId, event) - if swipeGesture then - table.insert(gestures, swipeGesture) - end - - -- Multi-touch gestures - if self:_getTouchCount() >= 2 then - local pinchGesture = self:_detectPinch(event) - if pinchGesture then - table.insert(gestures, pinchGesture) - end - local rotateGesture = self:_detectRotate(event) - if rotateGesture then - table.insert(gestures, rotateGesture) - end - end - end - elseif event.type == "touchrelease" then - local touch = self._touches[touchId] - if touch then - touch.phase = "ended" - - -- Finalize gesture detection - local tapGesture = self:_detectTapEnded(touchId, event) - if tapGesture then - table.insert(gestures, tapGesture) - end - local swipeGesture = self:_detectSwipeEnded(touchId, event) - if swipeGesture then - table.insert(gestures, swipeGesture) - end - local panGesture = self:_detectPanEnded(touchId, event) - if panGesture then - table.insert(gestures, panGesture) - end - - -- Cleanup touch - self._touches[touchId] = nil - end - elseif event.type == "touchcancel" then - -- Cancel all active gestures for this touch - self._touches[touchId] = nil - self:_cancelAllGestures() - end - - return #gestures > 0 and gestures or nil -end - ---- Get number of active touches ----@return number -function GestureRecognizer:_getTouchCount() - local count = 0 - for _ in pairs(self._touches) do - count = count + 1 - end - return count -end - ---- Detect tap gesture began ----@param touchId string ----@param event InputEvent -function GestureRecognizer:_detectTapBegan(touchId, event) - -- Tap detection happens on touch end - -- Just record the touch for now -end - ---- Detect tap gesture ended ----@param touchId string ----@param event InputEvent -function GestureRecognizer:_detectTapEnded(touchId, event) - local touch = self._touches[touchId] - if not touch then - return - end - - local duration = event.timestamp - touch.startTime - local dx = event.x - touch.startX - local dy = event.y - touch.startY - local distance = math.sqrt(dx * dx + dy * dy) - - -- Check if it's a valid tap - if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then - local currentTime = event.timestamp - local doubleTapState = self._gestureStates.doubleTap - - -- Check for double-tap - if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then - doubleTapState.tapCount = doubleTapState.tapCount + 1 - - if doubleTapState.tapCount >= 2 then - -- Fire double-tap gesture - return { - type = GestureType.DOUBLE_TAP, - state = GestureState.ENDED, - x = event.x, - y = event.y, - timestamp = event.timestamp, - } - end - else - doubleTapState.tapCount = 1 - end - - doubleTapState.lastTapTime = currentTime - - -- Fire tap gesture - return { - type = GestureType.TAP, - state = GestureState.ENDED, - x = event.x, - y = event.y, - timestamp = event.timestamp, - } - end -end - ---- Detect long-press gesture began ----@param touchId string ----@param event InputEvent -function GestureRecognizer:_detectLongPressBegan(touchId, event) - -- Long-press detection happens continuously during touch - self._gestureStates.longPress[touchId] = { - startX = event.x, - startY = event.y, - startTime = event.timestamp, - triggered = false, - } -end - ---- Detect pan gesture ----@param touchId string ----@param event InputEvent ----@return table? Gesture event -function GestureRecognizer:_detectPan(touchId, event) - local touch = self._touches[touchId] - if not touch then - return nil - end - - local dx = event.x - touch.startX - local dy = event.y - touch.startY - local distance = math.sqrt(dx * dx + dy * dy) - - local panState = self._gestureStates.pan[touchId] - - if not panState then - -- Check if pan should begin - if distance >= self._config.panMinMovement then - self._gestureStates.pan[touchId] = { - active = true, - lastX = touch.startX, - lastY = touch.startY, - } - panState = self._gestureStates.pan[touchId] - - return { - type = GestureType.PAN, - state = GestureState.BEGAN, - x = event.x, - y = event.y, - dx = dx, - dy = dy, - timestamp = event.timestamp, - } - end - else - -- Pan is active, fire changed event - local panDx = event.x - panState.lastX - local panDy = event.y - panState.lastY - - panState.lastX = event.x - panState.lastY = event.y - - return { - type = GestureType.PAN, - state = GestureState.CHANGED, - x = event.x, - y = event.y, - dx = panDx, - dy = panDy, - totalDx = dx, - totalDy = dy, - timestamp = event.timestamp, - } - end - - return nil -end - ---- Detect pan ended ----@param touchId string ----@param event InputEvent ----@return table? Gesture event -function GestureRecognizer:_detectPanEnded(touchId, event) - local panState = self._gestureStates.pan[touchId] - if panState and panState.active then - self._gestureStates.pan[touchId] = nil - - local touch = self._touches[touchId] - local dx = event.x - touch.startX - local dy = event.y - touch.startY - - return { - type = GestureType.PAN, - state = GestureState.ENDED, - x = event.x, - y = event.y, - dx = dx, - dy = dy, - timestamp = event.timestamp, - } - end - - return nil -end - ---- Detect swipe gesture ----@param touchId string ----@param event InputEvent -function GestureRecognizer:_detectSwipe(touchId, event) - -- Swipe detection happens on touch end -end - ---- Detect swipe ended ----@param touchId string ----@param event InputEvent ----@return table? Gesture event -function GestureRecognizer:_detectSwipeEnded(touchId, event) - local touch = self._touches[touchId] - if not touch then - return nil - end - - local duration = event.timestamp - touch.startTime - local dx = event.x - touch.startX - local dy = event.y - touch.startY - local distance = math.sqrt(dx * dx + dy * dy) - - -- Check if it's a valid swipe - if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then - local velocity = distance / duration - - if velocity >= self._config.swipeMinVelocity then - -- Determine swipe direction - local angle = math.atan2(dy, dx) - local direction = "right" - - if angle >= -math.pi / 4 and angle < math.pi / 4 then - direction = "right" - elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then - direction = "down" - elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then - direction = "up" - else - direction = "left" - end - - return { - type = GestureType.SWIPE, - state = GestureState.ENDED, - x = event.x, - y = event.y, - dx = dx, - dy = dy, - direction = direction, - velocity = velocity, - timestamp = event.timestamp, - } - end - end - - return nil -end - ---- Detect pinch gesture ----@param event InputEvent ----@return table? Gesture event -function GestureRecognizer:_detectPinch(event) - -- Get two touches for pinch - local touches = {} - for touchId, touch in pairs(self._touches) do - table.insert(touches, { id = touchId, touch = touch }) - if #touches >= 2 then - break - end - end - - if #touches < 2 then - return nil - end - - local t1 = touches[1].touch - local t2 = touches[2].touch - - -- Calculate current distance - local currentDx = t2.x - t1.x - local currentDy = t2.y - t1.y - local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy) - - -- Calculate initial distance - local initialDx = t2.startX - t1.startX - local initialDy = t2.startY - t1.startY - local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy) - - if initialDistance == 0 then - return nil - end - - -- Calculate scale - local scale = currentDistance / initialDistance - local pinchState = self._gestureStates.pinch - - if not pinchState.active then - -- Check if pinch should begin - if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then - pinchState.active = true - pinchState.initialScale = scale - pinchState.lastScale = scale - - -- Calculate center point - local centerX = (t1.x + t2.x) / 2 - local centerY = (t1.y + t2.y) / 2 - - return { - type = GestureType.PINCH, - state = GestureState.BEGAN, - scale = scale, - centerX = centerX, - centerY = centerY, - timestamp = event.timestamp, - } - end - else - -- Pinch is active, fire changed event - local centerX = (t1.x + t2.x) / 2 - local centerY = (t1.y + t2.y) / 2 - - local scaleChange = scale - pinchState.lastScale - pinchState.lastScale = scale - - return { - type = GestureType.PINCH, - state = GestureState.CHANGED, - scale = scale, - scaleChange = scaleChange, - centerX = centerX, - centerY = centerY, - timestamp = event.timestamp, - } - end - - return nil -end - ---- Detect rotate gesture ----@param event InputEvent ----@return table? Gesture event -function GestureRecognizer:_detectRotate(event) - -- Get two touches for rotation - local touches = {} - for touchId, touch in pairs(self._touches) do - table.insert(touches, { id = touchId, touch = touch }) - if #touches >= 2 then - break - end - end - - if #touches < 2 then - return nil - end - - local t1 = touches[1].touch - local t2 = touches[2].touch - - -- Calculate current angle - local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x) - - -- Calculate initial angle - local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX) - - -- Calculate rotation (in degrees) - local rotation = (currentAngle - initialAngle) * 180 / math.pi - - local rotateState = self._gestureStates.rotate - - if not rotateState.active then - -- Check if rotation should begin - if math.abs(rotation) >= self._config.rotateMinAngleChange then - rotateState.active = true - rotateState.initialRotation = rotation - rotateState.lastRotation = rotation - - -- Calculate center point - local centerX = (t1.x + t2.x) / 2 - local centerY = (t1.y + t2.y) / 2 - - return { - type = GestureType.ROTATE, - state = GestureState.BEGAN, - rotation = rotation, - centerX = centerX, - centerY = centerY, - timestamp = event.timestamp, - } - end - else - -- Rotation is active, fire changed event - local centerX = (t1.x + t2.x) / 2 - local centerY = (t1.y + t2.y) / 2 - - local rotationChange = rotation - rotateState.lastRotation - rotateState.lastRotation = rotation - - return { - type = GestureType.ROTATE, - state = GestureState.CHANGED, - rotation = rotation, - rotationChange = rotationChange, - centerX = centerX, - centerY = centerY, - timestamp = event.timestamp, - } - end - - return nil -end - ---- Cancel all active gestures -function GestureRecognizer:_cancelAllGestures() - for gestureType, state in pairs(self._gestureStates) do - if type(state) == "table" and state.active then - state.active = false - end - end -end - ---- Reset gesture recognizer state -function GestureRecognizer:reset() - self._touches = {} - self._gestureStates = { - tap = nil, - doubleTap = { lastTapTime = 0, tapCount = 0 }, - longPress = {}, - swipe = {}, - pan = {}, - pinch = { active = false }, - rotate = { active = false }, - } -end - --- Export gesture types and states -GestureRecognizer.GestureType = GestureType -GestureRecognizer.GestureState = GestureState - -return GestureRecognizer diff --git a/libs/flexlove/modules/Grid.lua b/libs/flexlove/modules/Grid.lua deleted file mode 100644 index d37600c7..00000000 --- a/libs/flexlove/modules/Grid.lua +++ /dev/null @@ -1,336 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local utils = require(modulePath .. "utils") -local enums = utils.enums -local Units = require(modulePath .. "Units") - -local Positioning = enums.Positioning -local AlignItems = enums.AlignItems - ---- Grid layout with variable column widths / row heights ---- Supports px, %, fr, auto, vw, vh, and calc track sizes -local Grid = {} - ---- Parse a single track spec into {type, value} ---- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc). ---- Grid-specific types (fr, auto) are handled directly. ----@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto") ----@param availableSize number Container size for % resolution ----@param viewportWidth number Viewport width for vw resolution ----@param viewportHeight number Viewport height for vh resolution ----@return table {type: "px"|"fr"|"auto", value: number} -function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight) - -- Handle calc objects (tables with _isCalc flag from FlexLove.calc()) - if type(spec) == "table" then - local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize) - return { type = "px", value = resolved } - end - - if type(spec) == "number" then - return { type = "px", value = spec } - end - - if type(spec) == "string" then - if spec == "auto" then - return { type = "auto", value = 0 } - end - - -- Check for fr unit (grid-specific, not in Units pipeline) - local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$") - if numStr and unit == "fr" then - local num = tonumber(numStr) - if num then - return { type = "fr", value = num } - end - end - - -- Delegate all other units to the Units pipeline (px, %, vw, vh, calc) - local parsedVal, parsedUnit = Units.parse(spec) - local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize) - return { type = "px", value = resolved } - end - - -- Default: 1fr - return { type = "fr", value = 1 } -end - ---- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks ----@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"}) ----@param availableSize number Container size for % resolution ----@param viewportWidth number Viewport width for vw resolution ----@param viewportHeight number Viewport height for vh resolution ----@return table Array of {type, value} track descriptors -function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight) - if type(spec) == "table" and #spec > 0 then - local tracks = {} - for i, s in ipairs(spec) do - tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight) - end - return tracks - end - -- Fallback: equal 1fr tracks - local count = (type(spec) == "number" and spec > 0) and spec or 1 - local tracks = {} - for i = 1, count do - tracks[i] = { type = "fr", value = 1 } - end - return tracks -end - ---- Measure intrinsic content sizes for auto tracks ---- Maps children to their tracks and computes each child's max-content contribution. ---- For children with explicit dimensions (units unit ~= "auto"), uses the original ---- explicit size. For auto-sized children, uses calculated content size. ---- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size ---- to the max-content contribution of their grid items. ----@param tracks table Array of {type, value} track descriptors ----@param children table Array of grid child elements ----@param axis "width"|"height" Dimension axis to measure -function Grid._measureAutoTracks(tracks, children, axis) - local trackSizes = {} - local numTracks = #tracks - - for i, child in ipairs(children) do - local index = i - 1 - local trackIdx = (index % numTracks) + 1 - - local intrinsicSize - if axis == "width" then - local unit = child.units and child.units.width and child.units.width.unit - if unit and unit ~= "auto" then - -- Explicit width: use original value + padding (not stretched border-box) - intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right - else - -- Auto-sized: use calculated content size - intrinsicSize = child:calculateAutoWidth() - end - else - local unit = child.units and child.units.height and child.units.height.unit - if unit and unit ~= "auto" then - intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom - else - intrinsicSize = child:calculateAutoHeight() - end - end - - if intrinsicSize > 0 then - trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize) - end - end - - -- Apply measured sizes to auto tracks - for i, track in ipairs(tracks) do - if track.type == "auto" and trackSizes[i] then - track.value = trackSizes[i] - end - end -end - ---- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining) ---- CSS Grid algorithm: ---- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks ---- 2. px tracks consume their fixed size ---- 3. fr tracks consume remaining free space proportionally ---- 4. If no fr tracks exist, auto tracks share remaining space equally ---- Mutates tracks in-place, converting all to {type="px", value=number} ----@param tracks table Array of {type, value} track descriptors ----@param availableSize number Total space available for tracks ----@param gap number Gap between tracks -function Grid._resolveTracks(tracks, availableSize, gap) - local count = #tracks - local totalGaps = (count > 1 and (count - 1) * gap) or 0 - local remaining = math.max(0, availableSize - totalGaps) - - -- Pass 1: Treat auto tracks as fixed (content-measured) and subtract - for _, track in ipairs(tracks) do - if track.type == "px" then - remaining = remaining - track.value - elseif track.type == "auto" then - remaining = remaining - math.max(0, track.value) - end - end - - remaining = math.max(0, remaining) - - -- Pass 2: Count fr shares - local totalFr = 0 - local autoCount = 0 - for _, track in ipairs(tracks) do - if track.type == "fr" then - totalFr = totalFr + track.value - elseif track.type == "auto" then - autoCount = autoCount + 1 - end - end - - -- Pass 3: Distribute remaining space - if totalFr > 0 then - -- fr tracks consume all remaining free space - local frUnit = remaining / totalFr - for _, track in ipairs(tracks) do - if track.type == "fr" then - track.value = frUnit * track.value - track.type = "px" - end - end - elseif autoCount > 0 then - -- No fr tracks: auto tracks share remaining space equally (grow beyond content) - local extraPerAuto = math.max(0, remaining) / autoCount - for _, track in ipairs(tracks) do - if track.type == "auto" then - track.value = track.value + extraPerAuto - track.type = "px" - end - end - end -end - ---- Layout grid items within a grid container ---- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs) ---- Falls back to equal-sized 1fr tracks when nil ----@param element Element -- Grid container element -function Grid.layoutGridItems(element) - -- Calculate space reserved by absolutely positioned siblings - local reservedLeft = 0 - local reservedRight = 0 - local reservedTop = 0 - local reservedBottom = 0 - - for _, child in ipairs(element.children) do - -- Only consider absolutely positioned children with explicit positioning and display != false - if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then - -- BORDER-BOX MODEL: Use border-box dimensions for space calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childBorderBoxHeight = child:getBorderBoxHeight() - - if child.left then - reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth) - end - if child.right then - reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth) - end - if child.top then - reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight) - end - if child.bottom then - reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight) - end - end - end - - -- Calculate available space (accounting for padding and reserved space) - -- BORDER-BOX MODEL: element.width and element.height are already content dimensions - local availableWidth = math.max(0, element.width - reservedLeft - reservedRight) - local availableHeight = math.max(0, element.height - reservedTop - reservedBottom) - - -- Get gaps - local columnGap = element.columnGap or 0 - local rowGap = element.rowGap or 0 - - -- Collect grid children (exclude explicitly absolute and display=false) - local gridChildren = {} - for _, child in ipairs(element.children) do - if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then - table.insert(gridChildren, child) - end - end - - -- Get viewport dimensions for unit resolution (vw, vh, %) - local vpw, vph = Units.getViewport() - - -- Build tracks, measure auto tracks by content, then resolve sizes - local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph) - local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph) - - Grid._measureAutoTracks(colTracks, gridChildren, "width") - Grid._measureAutoTracks(rowTracks, gridChildren, "height") - - Grid._resolveTracks(colTracks, availableWidth, columnGap) - Grid._resolveTracks(rowTracks, availableHeight, rowGap) - - -- Compute column start positions (for positioning) - local colStarts = {} - local currentX = element.x + element.padding.left + reservedLeft - for col = 1, #colTracks do - colStarts[col] = currentX - currentX = currentX + colTracks[col].value + columnGap - end - - local rowStarts = {} - local currentY = element.y + element.padding.top + reservedTop - for row = 1, #rowTracks do - rowStarts[row] = currentY - currentY = currentY + rowTracks[row].value + rowGap - end - - local effectiveAlignItems = element.alignItems or AlignItems.STRETCH - - for i, child in ipairs(gridChildren) do - -- Calculate row and column (0-indexed for calculation) - local index = i - 1 - local col = index % #colTracks - local row = math.floor(index / #colTracks) - - if row >= #rowTracks then - break - end - - -- Get resolved cell position and size - local colIdx = col + 1 - local rowIdx = row + 1 - local cellX = colStarts[colIdx] - local cellY = rowStarts[rowIdx] - local cellWidth = colTracks[colIdx].value - local cellHeight = rowTracks[rowIdx].value - - -- Apply alignment within grid cell (default to stretch) - -- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically - if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then - child.x = cellX - child.y = cellY - child._borderBoxWidth = cellWidth - child._borderBoxHeight = cellHeight - child.width = math.max(0, cellWidth - child.padding.left - child.padding.right) - child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom) - -- Disable auto-sizing when stretched by grid - child.autosizing.width = false - child.autosizing.height = false - elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then - local childBorderBoxWidth = child:getBorderBoxWidth() - local childBorderBoxHeight = child:getBorderBoxHeight() - child.x = cellX + (cellWidth - childBorderBoxWidth) / 2 - child.y = cellY + (cellHeight - childBorderBoxHeight) / 2 - elseif - effectiveAlignItems == AlignItems.FLEX_START - or effectiveAlignItems == "flex-start" - or effectiveAlignItems == "start" - then - child.x = cellX - child.y = cellY - elseif - effectiveAlignItems == AlignItems.FLEX_END - or effectiveAlignItems == "flex-end" - or effectiveAlignItems == "end" - then - local childBorderBoxWidth = child:getBorderBoxWidth() - local childBorderBoxHeight = child:getBorderBoxHeight() - child.x = cellX + cellWidth - childBorderBoxWidth - child.y = cellY + cellHeight - childBorderBoxHeight - else - child.x = cellX - child.y = cellY - child._borderBoxWidth = cellWidth - child._borderBoxHeight = cellHeight - child.width = math.max(0, cellWidth - child.padding.left - child.padding.right) - child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom) - -- Disable auto-sizing when stretched by grid - child.autosizing.width = false - child.autosizing.height = false - end - - if #child.children > 0 then - child:layoutChildren() - end - end -end - -return Grid diff --git a/libs/flexlove/modules/ImageCache.lua b/libs/flexlove/modules/ImageCache.lua deleted file mode 100644 index 206fa59c..00000000 --- a/libs/flexlove/modules/ImageCache.lua +++ /dev/null @@ -1,160 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - -local utils = req("utils") - --- ErrorHandler will be injected via init -local ErrorHandler = nil - ----@class ImageCache ----@field _cache table -local ImageCache = {} -ImageCache._cache = {} - ---- Initialize ImageCache with dependencies ----@param deps table Dependencies table with ErrorHandler -function ImageCache.init(deps) - if deps and deps.ErrorHandler then - ErrorHandler = deps.ErrorHandler - end -end - ---- Load an image from file path with caching ---- Returns cached image if already loaded, otherwise loads and caches it ----@param imagePath string -- Path to image file ----@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false) ----@return love.Image|nil -- Image object or nil on error ----@return string|nil -- Error message if loading failed -function ImageCache.load(imagePath, loadImageData) - if not imagePath or type(imagePath) ~= "string" or imagePath == "" then - return nil, "Invalid image path: path must be a non-empty string" - end - - local normalizedPath = utils.normalizePath(imagePath) - - if ImageCache._cache[normalizedPath] then - return ImageCache._cache[normalizedPath].image, nil - end - - local success, imageOrError = pcall(love.graphics.newImage, normalizedPath) - if not success then - if ErrorHandler then - ErrorHandler:warn("ImageCache", "RES_004", { - resourceType = "image", - path = imagePath, - error = tostring(imageOrError), - }) - end - return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError)) - end - - local image = imageOrError - local imgData = nil - - if loadImageData then - local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath) - if dataSuccess then - imgData = dataOrError - elseif ErrorHandler then - ErrorHandler:warn("ImageCache", "RES_004", { - resourceType = "image data", - path = imagePath, - error = tostring(dataOrError), - }) - end - end - - ImageCache._cache[normalizedPath] = { - image = image, - imageData = imgData, - } - - return image, nil -end - ---- Get a cached image without loading ----@param imagePath string -- Path to image file ----@return love.Image|nil -- Cached image or nil if not found -function ImageCache.get(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return nil - end - - local normalizedPath = utils.normalizePath(imagePath) - local cached = ImageCache._cache[normalizedPath] - return cached and cached.image or nil -end - ---- Get cached ImageData for an image ----@param imagePath string -- Path to image file ----@return love.ImageData|nil -- Cached ImageData or nil if not found -function ImageCache.getImageData(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return nil - end - - local normalizedPath = utils.normalizePath(imagePath) - local cached = ImageCache._cache[normalizedPath] - return cached and cached.imageData or nil -end - ---- Remove a specific image from cache ----@param imagePath string -- Path to image file to remove ----@return boolean -- True if image was removed, false if not found -function ImageCache.remove(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return false - end - - local normalizedPath = utils.normalizePath(imagePath) - if ImageCache._cache[normalizedPath] then - local cached = ImageCache._cache[normalizedPath] - if cached.image then - cached.image:release() - end - if cached.imageData then - cached.imageData:release() - end - ImageCache._cache[normalizedPath] = nil - return true - end - return false -end - ---- Clear all cached images -function ImageCache.clear() - for path, cached in pairs(ImageCache._cache) do - if cached.image then - cached.image:release() - end - if cached.imageData then - cached.imageData:release() - end - end - ImageCache._cache = {} -end - ---- Get cache statistics ----@return {count: number, memoryEstimate: number} -- Cache stats -function ImageCache.getStats() - local count = 0 - local memoryEstimate = 0 - - for path, cached in pairs(ImageCache._cache) do - count = count + 1 - if cached.image then - local w, h = cached.image:getDimensions() - -- Estimate: 4 bytes per pixel (RGBA) - memoryEstimate = memoryEstimate + (w * h * 4) - end - end - - return { - count = count, - memoryEstimate = memoryEstimate, - } -end - -return ImageCache diff --git a/libs/flexlove/modules/ImageRenderer.lua b/libs/flexlove/modules/ImageRenderer.lua deleted file mode 100644 index 886c30a2..00000000 --- a/libs/flexlove/modules/ImageRenderer.lua +++ /dev/null @@ -1,380 +0,0 @@ ----@class ImageRenderer -local ImageRenderer = {} - --- ErrorHandler and utils will be injected via init -local ErrorHandler = nil -local utils = nil - ---- Initialize ImageRenderer with dependencies ----@param deps table Dependencies table with ErrorHandler and utils -function ImageRenderer.init(deps) - if deps and deps.ErrorHandler then - ErrorHandler = deps.ErrorHandler - end - if deps and deps.utils then - utils = deps.utils - end -end - ---- Calculate rendering parameters for object-fit modes ---- Returns source and destination rectangles for rendering ----@param imageWidth number -- Natural width of the image ----@param imageHeight number -- Natural height of the image ----@param boundsWidth number -- Width of the bounds to fit within ----@param boundsHeight number -- Height of the bounds to fit within ----@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill") ----@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center") ----@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number} -function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition) - fitMode = fitMode or "fill" - objectPosition = objectPosition or "center center" - - if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then - ErrorHandler:error("ImageRenderer", "VAL_002", { - imageWidth = imageWidth, - imageHeight = imageHeight, - boundsWidth = boundsWidth, - boundsHeight = boundsHeight, - }) - end - - local result = { - sx = 0, -- Source X - sy = 0, -- Source Y - sw = imageWidth, -- Source width - sh = imageHeight, -- Source height - dx = 0, -- Destination X - dy = 0, -- Destination Y - dw = boundsWidth, -- Destination width - dh = boundsHeight, -- Destination height - scaleX = 1, -- Scale factor X - scaleY = 1, -- Scale factor Y - } - - if fitMode == "fill" then - -- Stretch to fill bounds (may distort) - result.scaleX = boundsWidth / imageWidth - result.scaleY = boundsHeight / imageHeight - result.dw = boundsWidth - result.dh = boundsHeight - elseif fitMode == "contain" then - -- Scale to fit within bounds (preserves aspect ratio) - local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight) - result.scaleX = scale - result.scaleY = scale - result.dw = imageWidth * scale - result.dh = imageHeight * scale - - -- Apply object-position for letterbox alignment - local posX, posY = ImageRenderer._parsePosition(objectPosition) - result.dx = (boundsWidth - result.dw) * posX - result.dy = (boundsHeight - result.dh) * posY - elseif fitMode == "cover" then - -- Scale to cover bounds (preserves aspect ratio, may crop) - local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight) - result.scaleX = scale - result.scaleY = scale - - local scaledWidth = imageWidth * scale - local scaledHeight = imageHeight * scale - - -- Apply object-position for crop alignment - local posX, posY = ImageRenderer._parsePosition(objectPosition) - - -- Calculate which part of the scaled image to show - local cropX = (scaledWidth - boundsWidth) * posX - local cropY = (scaledHeight - boundsHeight) * posY - - -- Convert back to source coordinates - result.sx = cropX / scale - result.sy = cropY / scale - result.sw = boundsWidth / scale - result.sh = boundsHeight / scale - - result.dx = 0 - result.dy = 0 - result.dw = boundsWidth - result.dh = boundsHeight - elseif fitMode == "none" then - -- Use natural size (no scaling) - result.scaleX = 1 - result.scaleY = 1 - result.dw = imageWidth - result.dh = imageHeight - - -- Apply object-position - local posX, posY = ImageRenderer._parsePosition(objectPosition) - result.dx = (boundsWidth - imageWidth) * posX - result.dy = (boundsHeight - imageHeight) * posY - elseif fitMode == "scale-down" then - -- Use none or contain, whichever is smaller - if imageWidth <= boundsWidth and imageHeight <= boundsHeight then - -- Image fits naturally, use "none" - return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition) - else - -- Image too large, use "contain" - return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition) - end - else - ErrorHandler:warn("ImageRenderer", "VAL_007", { - fitMode = fitMode, - fallback = "fill", - }) - -- Use 'fill' as fallback - return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition) - end - - return result -end - ---- Parse object-position string into normalized coordinates (0-1) ---- Supports keywords (center, top, bottom, left, right) and percentages ----@param position string -- Position string like "center center", "top left", "50% 50%" ----@return number, number -- Normalized X and Y positions (0-1) -function ImageRenderer._parsePosition(position) - if not position or type(position) ~= "string" then - return 0.5, 0.5 -- Default to center - end - - -- Split into X and Y components - local parts = {} - for part in position:gmatch("%S+") do - table.insert(parts, part:lower()) - end - - -- If only one value, use it for both axes (with special handling) - if #parts == 1 then - local val = parts[1] - if val == "left" or val == "right" then - parts = { val, "center" } - elseif val == "top" or val == "bottom" then - parts = { "center", val } - else - parts = { val, val } - end - elseif #parts == 0 then - return 0.5, 0.5 -- Default to center - end - - local function parseValue(val) - -- Handle keywords - if val == "center" then - return 0.5 - elseif val == "left" or val == "top" then - return 0 - elseif val == "right" or val == "bottom" then - return 1 - end - - -- Handle percentages - local percent = val:match("^([%d%.]+)%%$") - if percent then - return tonumber(percent) / 100 - end - - -- Handle plain numbers (treat as percentage) - local num = tonumber(val) - if num then - return num / 100 - end - - -- Invalid value, default to center - return 0.5 - end - - local x = parseValue(parts[1]) - local y = parseValue(parts[2] or parts[1]) - - -- Clamp to 0-1 range - x = math.max(0, math.min(1, x)) - y = math.max(0, math.min(1, y)) - - return x, y -end - ---- Draw an image with specified object-fit mode ----@param image love.Image -- Image to draw ----@param x number -- X position of bounds ----@param y number -- Y position of bounds ----@param width number -- Width of bounds ----@param height number -- Height of bounds ----@param fitMode string? -- Object-fit mode (default: "fill") ----@param objectPosition string? -- Object-position (default: "center center") ----@param opacity number? -- Opacity 0-1 (default: 1) ----@param tintColor Color? -- Color to tint the image (default: white/no tint) -function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor) - if not image then - return -- Nothing to draw - end - - opacity = opacity or 1 - fitMode = fitMode or "fill" - objectPosition = objectPosition or "center center" - - local imgWidth, imgHeight = image:getDimensions() - local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition) - - -- Save current color - local r, g, b, a = love.graphics.getColor() - - -- Apply opacity and tint - if tintColor then - love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) - else - love.graphics.setColor(1, 1, 1, opacity) - end - - -- Draw image - if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then - -- Need to use a quad for cropping - local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight) - love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh) - else - -- Simple draw with scaling - love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY) - end - - -- Restore color - love.graphics.setColor(r, g, b, a) -end - ---- Draw an image with tiling/repeat mode ----@param image love.Image -- Image to draw ----@param x number -- X position of bounds ----@param y number -- Y position of bounds ----@param width number -- Width of bounds ----@param height number -- Height of bounds ----@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat") ----@param opacity number? -- Opacity 0-1 (default: 1) ----@param tintColor Color? -- Color to tint the image (default: white/no tint) -function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor) - if not image then - return -- Nothing to draw - end - - opacity = opacity or 1 - repeatMode = repeatMode or "no-repeat" - - local imgWidth, imgHeight = image:getDimensions() - - -- Save current color - local r, g, b, a = love.graphics.getColor() - - -- Apply opacity and tint - if tintColor then - love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) - else - love.graphics.setColor(1, 1, 1, opacity) - end - - if repeatMode == "no-repeat" then - -- Just draw once, no tiling - love.graphics.draw(image, x, y) - elseif repeatMode == "repeat" then - -- Tile in both directions - local tilesX = math.ceil(width / imgWidth) - local tilesY = math.ceil(height / imgHeight) - - for tileY = 0, tilesY - 1 do - for tileX = 0, tilesX - 1 do - local drawX = x + (tileX * imgWidth) - local drawY = y + (tileY * imgHeight) - - -- Calculate how much of the tile to draw (for partial tiles at edges) - local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) - local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) - - if drawWidth < imgWidth or drawHeight < imgHeight then - -- Use quad for partial tile - local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight) - love.graphics.draw(image, quad, drawX, drawY) - else - -- Draw full tile - love.graphics.draw(image, drawX, drawY) - end - end - end - elseif repeatMode == "repeat-x" then - -- Tile horizontally only - local tilesX = math.ceil(width / imgWidth) - - for tileX = 0, tilesX - 1 do - local drawX = x + (tileX * imgWidth) - local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) - - if drawWidth < imgWidth then - -- Use quad for partial tile - local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight) - love.graphics.draw(image, quad, drawX, y) - else - -- Draw full tile - love.graphics.draw(image, drawX, y) - end - end - elseif repeatMode == "repeat-y" then - -- Tile vertically only - local tilesY = math.ceil(height / imgHeight) - - for tileY = 0, tilesY - 1 do - local drawY = y + (tileY * imgHeight) - local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) - - if drawHeight < imgHeight then - -- Use quad for partial tile - local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight) - love.graphics.draw(image, quad, x, drawY) - else - -- Draw full tile - love.graphics.draw(image, x, drawY) - end - end - elseif repeatMode == "space" then - -- Distribute tiles with even spacing - local tilesX = math.floor(width / imgWidth) - local tilesY = math.floor(height / imgHeight) - - if tilesX < 1 then - tilesX = 1 - end - if tilesY < 1 then - tilesY = 1 - end - - local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0 - local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0 - - for tileY = 0, tilesY - 1 do - for tileX = 0, tilesX - 1 do - local drawX = x + (tileX * (imgWidth + spaceX)) - local drawY = y + (tileY * (imgHeight + spaceY)) - love.graphics.draw(image, drawX, drawY) - end - end - elseif repeatMode == "round" then - -- Scale tiles to fit bounds exactly - local tilesX = math.max(1, utils.round(width / imgWidth)) - local tilesY = math.max(1, utils.round(height / imgHeight)) - - local scaleX = width / (tilesX * imgWidth) - local scaleY = height / (tilesY * imgHeight) - - for tileY = 0, tilesY - 1 do - for tileX = 0, tilesX - 1 do - local drawX = x + (tileX * imgWidth * scaleX) - local drawY = y + (tileY * imgHeight * scaleY) - love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY) - end - end - else - ErrorHandler:warn("ImageRenderer", "VAL_007", { - repeatMode = repeatMode, - fallback = "no-repeat", - }) - love.graphics.draw(image, x, y) - end - - -- Restore color - love.graphics.setColor(r, g, b, a) -end - -return ImageRenderer diff --git a/libs/flexlove/modules/ImageScaler.lua b/libs/flexlove/modules/ImageScaler.lua deleted file mode 100644 index cf510bf3..00000000 --- a/libs/flexlove/modules/ImageScaler.lua +++ /dev/null @@ -1,174 +0,0 @@ --- ==================== --- ImageScaler --- ==================== - -local ImageScaler = {} - --- ErrorHandler will be injected via init -local ErrorHandler = nil - ---- Initialize ImageScaler with dependencies ----@param deps table Dependencies table with ErrorHandler -function ImageScaler.init(deps) - if deps and deps.ErrorHandler then - ErrorHandler = deps.ErrorHandler - end -end - ---- Scale an ImageData region using nearest-neighbor sampling ---- Produces sharp, pixelated scaling - ideal for pixel art ----@param sourceImageData love.ImageData -- Source image data ----@param srcX number -- Source region X (0-based) ----@param srcY number -- Source region Y (0-based) ----@param srcW number -- Source region width ----@param srcH number -- Source region height ----@param destW number -- Destination width ----@param destH number -- Destination height ----@return love.ImageData -- Scaled image data -function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) - if not sourceImageData then - ErrorHandler:error("ImageScaler", "VAL_001", { - parameter = "sourceImageData", - }) - end - - if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then - ErrorHandler:warn("ImageScaler", "VAL_002", { - srcW = srcW, - srcH = srcH, - destW = destW, - destH = destH, - fallback = "1x1 transparent image", - }) - -- Return a minimal 1x1 transparent image as fallback - local fallbackImageData = love.image.newImageData(1, 1) - fallbackImageData:setPixel(0, 0, 0, 0, 0, 0) - return fallbackImageData - end - - -- Create destination ImageData - local destImageData = love.image.newImageData(destW, destH) - - -- Calculate scale ratios (cached outside loops for performance) - local scaleX = srcW / destW - local scaleY = srcH / destH - - -- Nearest-neighbor sampling - for destY = 0, destH - 1 do - for destX = 0, destW - 1 do - -- Calculate source pixel coordinates using floor (nearest-neighbor) - local srcPixelX = math.floor(destX * scaleX) + srcX - local srcPixelY = math.floor(destY * scaleY) + srcY - - -- Clamp to source bounds (safety check) - srcPixelX = math.min(srcPixelX, srcX + srcW - 1) - srcPixelY = math.min(srcPixelY, srcY + srcH - 1) - - -- Sample source pixel - local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY) - - -- Write to destination - destImageData:setPixel(destX, destY, r, g, b, a) - end - end - - return destImageData -end - ---- Linear interpolation helper ---- Blends between two values based on interpolation factor ----@param a number -- Start value ----@param b number -- End value ----@param t number -- Interpolation factor [0, 1] ----@return number -- Interpolated value -local function lerp(a, b, t) - return a + (b - a) * t -end - ---- Scale an ImageData region using bilinear interpolation ---- Produces smooth, filtered scaling - ideal for high-quality upscaling ----@param sourceImageData love.ImageData -- Source image data ----@param srcX number -- Source region X (0-based) ----@param srcY number -- Source region Y (0-based) ----@param srcW number -- Source region width ----@param srcH number -- Source region height ----@param destW number -- Destination width ----@param destH number -- Destination height ----@return love.ImageData -- Scaled image data -function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) - if not sourceImageData then - ErrorHandler:error("ImageScaler", "VAL_001", { - parameter = "sourceImageData", - }) - end - - if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then - ErrorHandler:warn("ImageScaler", "VAL_002", { - srcW = srcW, - srcH = srcH, - destW = destW, - destH = destH, - fallback = "1x1 transparent image", - }) - -- Return a minimal 1x1 transparent image as fallback - local fallbackImageData = love.image.newImageData(1, 1) - fallbackImageData:setPixel(0, 0, 0, 0, 0, 0) - return fallbackImageData - end - - -- Create destination ImageData - local destImageData = love.image.newImageData(destW, destH) - - -- Calculate scale ratios - local scaleX = srcW / destW - local scaleY = srcH / destH - - -- Bilinear interpolation - for destY = 0, destH - 1 do - for destX = 0, destW - 1 do - -- Calculate fractional source position - local srcXf = destX * scaleX - local srcYf = destY * scaleY - - -- Get integer coordinates for 2x2 sampling grid - local x0 = math.floor(srcXf) - local y0 = math.floor(srcYf) - local x1 = math.min(x0 + 1, srcW - 1) - local y1 = math.min(y0 + 1, srcH - 1) - - -- Get fractional parts for interpolation - local fx = srcXf - x0 - local fy = srcYf - y0 - - -- Sample 4 neighboring pixels (with source offset) - local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0) - local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0) - local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1) - local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1) - - -- Interpolate horizontally (top and bottom rows) - local rTop = lerp(r00, r10, fx) - local gTop = lerp(g00, g10, fx) - local bTop = lerp(b00, b10, fx) - local aTop = lerp(a00, a10, fx) - - local rBottom = lerp(r01, r11, fx) - local gBottom = lerp(g01, g11, fx) - local bBottom = lerp(b01, b11, fx) - local aBottom = lerp(a01, a11, fx) - - -- Interpolate vertically (final result) - local r = lerp(rTop, rBottom, fy) - local g = lerp(gTop, gBottom, fy) - local b = lerp(bTop, bBottom, fy) - local a = lerp(aTop, aBottom, fy) - - -- Write to destination - destImageData:setPixel(destX, destY, r, g, b, a) - end - end - - return destImageData -end - -return ImageScaler diff --git a/libs/flexlove/modules/InputEvent.lua b/libs/flexlove/modules/InputEvent.lua deleted file mode 100644 index 8f1be533..00000000 --- a/libs/flexlove/modules/InputEvent.lua +++ /dev/null @@ -1,88 +0,0 @@ ----@class InputEvent ----@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel" ----@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle) ----@field x number -- Mouse/Touch X position ----@field y number -- Mouse/Touch Y position ----@field dx number? -- Delta X from drag/touch start (only for drag/touch events) ----@field dy number? -- Delta Y from drag/touch start (only for drag/touch events) ----@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} ----@field clickCount number -- Number of clicks (for double/triple click detection) ----@field timestamp number -- Time when event occurred ----@field touchId string? -- Touch identifier (for multi-touch) ----@field pressure number? -- Touch pressure (0-1, defaults to 1.0) ----@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled" -local InputEvent = {} -InputEvent.__index = InputEvent - ----@class InputEventProps ----@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel" ----@field button number ----@field x number ----@field y number ----@field dx number? ----@field dy number? ----@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} ----@field clickCount number? ----@field timestamp number? ----@field touchId string? ----@field pressure number? ----@field phase string? - ---- Create a new input event ----@param props InputEventProps ----@return InputEvent -function InputEvent.new(props) - local self = setmetatable({}, InputEvent) - self.type = props.type - self.button = props.button - self.x = props.x - self.y = props.y - self.dx = props.dx - self.dy = props.dy - self.modifiers = props.modifiers - self.clickCount = props.clickCount or 1 - self.timestamp = props.timestamp or love.timer.getTime() - - -- Touch-specific properties - self.touchId = props.touchId - self.pressure = props.pressure or 1.0 - self.phase = props.phase - - return self -end - ---- Create an InputEvent from LÖVE touch data ----@param id userdata Touch ID from LÖVE ----@param x number Touch X position ----@param y number Touch Y position ----@param phase string Touch phase: "began", "moved", "ended", "cancelled" ----@param pressure number? Touch pressure (0-1, defaults to 1.0) ----@return InputEvent -function InputEvent.fromTouch(id, x, y, phase, pressure) - local touchIdStr = tostring(id) - local eventType = "touchpress" - if phase == "moved" then - eventType = "touchmove" - elseif phase == "ended" then - eventType = "touchrelease" - elseif phase == "cancelled" then - eventType = "touchcancel" - end - - return InputEvent.new({ - type = eventType, - button = 1, -- Treat touch as left button - x = x, - y = y, - dx = 0, - dy = 0, - modifiers = { shift = false, ctrl = false, alt = false, super = false }, - clickCount = 1, - timestamp = love.timer.getTime(), - touchId = touchIdStr, - pressure = pressure or 1.0, - phase = phase, - }) -end - -return InputEvent diff --git a/libs/flexlove/modules/KeyboardNavigation.lua b/libs/flexlove/modules/KeyboardNavigation.lua deleted file mode 100644 index 7122cd40..00000000 --- a/libs/flexlove/modules/KeyboardNavigation.lua +++ /dev/null @@ -1,748 +0,0 @@ -local packageName = ... or "KeyboardNavigation" -local modulePath = packageName:match("(.-)[^%.]+$") - -local function req(name) - return require(modulePath .. name) -end - ----@class KeyboardNavigation ----@field config KeyboardNavigationConfig -local KeyboardNavigation = { - - config = { - -- Global settings - enabled = true, - debugMode = false, - - -- Key bindings - keys = { - next = "tab", - previous = "shifttab", - up = "up", - down = "down", - left = "left", - right = "right", - activate = { "return", "space" }, - dismiss = "escape", - toggleDebug = "f12", - inspect = "i", - }, - - -- Navigation behavior - wrapAround = true, - directionalNavigation = true, - focusVisible = true, - autofocusOnCreate = false, - - --- Drop focus after pressing Enter/Space to activate an element - --- When false, focus remains on the element after activation - dropFocusOnSelection = true, - - -- Developer tools - developerTools = { - enabled = true, - showProperties = true, - highlightColor = { 1, 0.8, 0, 0.5 }, - }, - - -- Focus indicator style - focusIndicator = { - color = { 0.2, 0.6, 1.0, 0.8 }, - lineWidth = 2, - inset = -3, - borderRadius = 4, - animationDuration = 0.15, - }, - }, - - -- State - _navigationStack = {}, - _lastNavigationTime = 0, - _inspectMode = false, - _deps = nil, - - -- Spatial index for directional navigation (performance optimization) - _spatialIndex = { - enabled = false, - cellSize = 100, -- Grid cell size in pixels - grid = {}, -- Grid storing element references - elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}} - lastUpdateFrame = 0, - }, -} - ---- Initialize KeyboardNavigation module ----@param deps table {Context, Element, ErrorHandler, utils, InputEvent} -function KeyboardNavigation.init(deps) - -- Validate required dependencies - local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true } - for depName, _ in pairs(required) do - if not deps[depName] then - error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName)) - end - end - - KeyboardNavigation._deps = deps - KeyboardNavigation._ErrorHandler = deps.ErrorHandler - KeyboardNavigation._InputEvent = deps.InputEvent - KeyboardNavigation._Context = deps.Context - KeyboardNavigation._Element = deps.Element - KeyboardNavigation._utils = deps.utils -end - ---- Handle keyboard press for navigation ----@param key string ----@param scancode string ----@param isrepeat boolean ----@return boolean handled -function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat) - if not KeyboardNavigation._Context then - return false - end - - -- Debug logging - if KeyboardNavigation.config.debugMode then - print( - string.format( - "[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)", - key, - scancode, - tostring(isrepeat) - ) - ) - print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled))) - end - - local config = KeyboardNavigation.config - local keys = config.keys - - -- Check for activation keys - for _, activateKey in ipairs(keys.activate) do - if key == activateKey then - return self:activateElement() - end - end - - -- Check for dismiss key - if key == keys.dismiss then - return self:dismissElement() - end - - -- Check for next/previous navigation - -- Tab with shift held = previous; Tab without shift = next - if key == keys.next then - if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then - return self:previousFocusable() - end - return self:nextFocusable() - end - - if key == keys.previous then - return self:previousFocusable() - end - - -- Check for directional navigation - if config.directionalNavigation then - if key == keys.up then - return self:navigateDirectional("up") - elseif key == keys.down then - return self:navigateDirectional("down") - elseif key == keys.left then - return self:navigateDirectional("left") - elseif key == keys.right then - return self:navigateDirectional("right") - end - end - - return false -end - ---- Find next focusable element in the focusable list ----@param focusableList table List of focusable elements in tab order ----@param current Element? Currently focused element ----@return Element? -function KeyboardNavigation:_findNextInList(focusableList, current) - local currentIndex = 0 - if current then - for i, elem in ipairs(focusableList) do - if elem.id == current.id then - currentIndex = i - break - end - end - end - - -- Search forward - if currentIndex < #focusableList then - return focusableList[currentIndex + 1] - end - - -- Wrap around if enabled - if KeyboardNavigation.config.wrapAround and #focusableList > 0 then - return focusableList[1] - end - - return nil -end - ---- Get the focusable element list scoped to the navigation container ----@return Element[] -function KeyboardNavigation:_getScopedFocusableList() - local Context = KeyboardNavigation._Context - local container = Context.getNavigationContainer() - if container then - return container:getFocusableChildren() - end - return Context.getFocusableElements() -end - ---- Navigate to next focusable element (Tab) ----@return boolean success -function KeyboardNavigation:nextFocusable() - local Context = KeyboardNavigation._Context - - local current = Context.getFocused() - if KeyboardNavigation.config.debugMode then - print( - string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil")) - ) - end - - local focusableList = self:_getScopedFocusableList() - local nextElem = self:_findNextInList(focusableList, current) - - if nextElem then - self:_focusElement(nextElem) - return true - end - - return false -end - ---- Find previous focusable element in the focusable list ----@param focusableList table List of focusable elements in tab order ----@param current Element? Currently focused element ----@return Element? -function KeyboardNavigation:_findPreviousInList(focusableList, current) - local currentIndex = #focusableList + 1 - if current then - for i, elem in ipairs(focusableList) do - if elem.id == current.id then - currentIndex = i - break - end - end - end - - -- Search backward - if currentIndex - 1 >= 1 then - return focusableList[currentIndex - 1] - end - - -- Wrap around if enabled - if KeyboardNavigation.config.wrapAround and #focusableList > 0 then - return focusableList[#focusableList] - end - - return nil -end - ---- Navigate to previous focusable element (Shift+Tab) ----@return boolean success -function KeyboardNavigation:previousFocusable() - local Context = KeyboardNavigation._Context - - local current = Context.getFocused() - - local focusableList = self:_getScopedFocusableList() - local prevElem = self:_findPreviousInList(focusableList, current) - - if prevElem then - self:_focusElement(prevElem) - return true - end - - return false -end - ---- Navigate using arrow keys ----@param direction "up"|"down"|"left"|"right" ----@return boolean success -function KeyboardNavigation:navigateDirectional(direction) - local Context = KeyboardNavigation._Context - local current = Context.getFocused() - - if not current then - return false - end - - local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction) - - if nextElem then - self:_focusElement(nextElem) - return true - end - - return false -end - ---- Find closest focusable element in the given direction ----@param current Element ----@param direction "up"|"down"|"left"|"right" ----@return Element? -function KeyboardNavigation:_findDirectionalNeighbor(current, direction) - -- Try spatial index first if enabled - if KeyboardNavigation._spatialIndex.enabled then - local spatialResult = self:_findDirectionalNeighborSpatial(current, direction) - if spatialResult then - return spatialResult - end - end - - -- Collect all focusable elements visible this frame - local Context = KeyboardNavigation._Context - local focusable = {} - - local function collectFocusable(elem) - if elem:isFocusable() and elem ~= current then - table.insert(focusable, elem) - end - for _, child in ipairs(elem.children) do - collectFocusable(child) - end - end - - -- Mode-agnostic: collect from Context's focusable list - local allFocusable = Context.getFocusableElements() - for _, elem in ipairs(allFocusable) do - if elem ~= current then - table.insert(focusable, elem) - end - end - - if #focusable == 0 then - return nil - end - - local currentRect = { - x = current.x, - y = current.y, - width = current.width or 0, - height = current.height or 0, - } - - local closest = nil - local closestDistance = math.huge - - for _, elem in ipairs(focusable) do - local elemRect = { - x = elem.x, - y = elem.y, - width = elem.width or 0, - height = elem.height or 0, - } - - local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction) - - if isInDirection and distance < closestDistance then - closest = elem - closestDistance = distance - end - end - - -- If no element found in exact direction, try with looser criteria - if not closest then - closest = self:_findClosestInDirection(current, focusable, direction) - end - - return closest -end - ---- Calculate distance and direction between elements ----@param from table {x, y, width, height} ----@param to table {x, y, width, height} ----@param direction string ----@return number distance, boolean isInDirection -function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction) - -- Calculate bounding box edges - local fromLeft = from.x - local fromRight = from.x + from.width - local fromTop = from.y - local fromBottom = from.y + from.height - - local toLeft = to.x - local toRight = to.x + to.width - local toTop = to.y - local toBottom = to.y + to.height - - local distance = math.huge - local isInDirection = false - - if direction == "up" then - if toBottom < fromTop then - isInDirection = true - distance = fromTop - toBottom - end - elseif direction == "down" then - if toTop > fromBottom then - isInDirection = true - distance = toTop - fromBottom - end - elseif direction == "left" then - if toRight < fromLeft then - isInDirection = true - distance = fromLeft - toRight - end - elseif direction == "right" then - if toLeft > fromRight then - isInDirection = true - distance = toLeft - fromRight - end - end - - return distance, isInDirection -end - ---- Find closest element in direction using center-to-center distance ----@param current Element ----@param focusable Element[] ----@param direction string ----@return Element? -function KeyboardNavigation:_findClosestInDirection(current, focusable, direction) - local currentCenterX = current.x + (current.width or 0) / 2 - local currentCenterY = current.y + (current.height or 0) / 2 - - local closest = nil - local closestDistance = math.huge - - for _, elem in ipairs(focusable) do - if elem ~= current then - local elemCenterX = elem.x + (elem.width or 0) / 2 - local elemCenterY = elem.y + (elem.height or 0) / 2 - - local dx = elemCenterX - currentCenterX - local dy = elemCenterY - currentCenterY - - -- Check if element is generally in the right direction - local isInDirection = false - - if direction == "up" and dy < 0 then - isInDirection = true - elseif direction == "down" and dy > 0 then - isInDirection = true - elseif direction == "left" and dx < 0 then - isInDirection = true - elseif direction == "right" and dx > 0 then - isInDirection = true - end - - if isInDirection then - local distance = math.sqrt(dx * dx + dy * dy) - if distance < closestDistance then - closest = elem - closestDistance = distance - end - end - end - end - - return closest -end - ---- Focus an element ----@param element Element -function KeyboardNavigation:_focusElement(element) - local Context = KeyboardNavigation._Context - - if element and element:isFocusable() then - if KeyboardNavigation.config.debugMode then - print( - string.format( - "[KeyboardNavigation] Focusing element: %s (id: %s)", - element.themeComponent or "unknown", - tostring(element.id) - ) - ) - end - Context.setFocused(element) - - -- Update focus indicator - if KeyboardNavigation.FocusIndicator then - KeyboardNavigation.FocusIndicator.setFocused(element) - end - - -- Call onFocus callback if it exists - if element.onFocus then - local success, err = pcall(function() - if element.onFocusDeferred then - table.insert(Context._deferredCallbacks or {}, function() - element:onFocus(element) - end) - else - element:onFocus(element) - end - end) - - if not success then - KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", { - elementId = element.id or "unknown", - error = tostring(err), - }) - end - end - end -end - ----@param element Element ----@return boolean -function KeyboardNavigation:_shouldDropFocusOnSelection(element) - if element and element.dropFocusOnSelection ~= nil then - return element.dropFocusOnSelection == true - end - - return KeyboardNavigation.config.dropFocusOnSelection == true -end - ---- Activate currently focused element ----@return boolean success -function KeyboardNavigation:activateElement() - local Context = KeyboardNavigation._Context - local focused = Context.getFocused() - - if not focused then - return false - end - - if focused.disabled then - return false - end - - -- Fire press and release events - if focused.onEvent then - local modifiers = KeyboardNavigation._utils.getModifiers() - local pressEvent = KeyboardNavigation._InputEvent.new({ - type = "press", - button = 1, - x = focused.x, - y = focused.y, - modifiers = modifiers, - clickCount = 1, - }) - - local releaseEvent = KeyboardNavigation._InputEvent.new({ - type = "release", - button = 1, - x = focused.x, - y = focused.y, - modifiers = modifiers, - clickCount = 1, - }) - - local success, err = pcall(function() - focused.onEvent(focused, pressEvent) - focused.onEvent(focused, releaseEvent) - end) - - if not success then - KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", { - elementId = focused.id or "unknown", - error = tostring(err), - }) - end - - -- Drop focus after selection based on per-element override or global config. - if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then - Context.clearFocus() - if KeyboardNavigation.FocusIndicator then - KeyboardNavigation.FocusIndicator.setFocused(nil) - end - end - - return true - end - - return false -end - ---- Dismiss currently focused element ----@return boolean success -function KeyboardNavigation:dismissElement() - local Context = KeyboardNavigation._Context - local focused = Context.getFocused() - - if not focused then - return false - end - - -- Check if element has a dismiss handler - if focused.onDismiss then - local success, err = pcall(function() - if focused.onDismissDeferred then - table.insert(Context._deferredCallbacks or {}, function() - focused:onDismiss(focused) - end) - else - focused:onDismiss(focused) - end - end) - - if not success then - KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", { - elementId = focused.id or "unknown", - error = tostring(err), - }) - end - - return true -- Handler took care of dismissal - end - - -- Default behavior: blur the element (only if no onDismiss handler) - Context.clearFocus() - return true -end - ---- Update keyboard navigation (for animations, etc.) ----@param dt number -function KeyboardNavigation:update(dt) - -- Update focus indicator if it exists - if KeyboardNavigation.FocusIndicator then - KeyboardNavigation.FocusIndicator:update(dt) - end -end - ---- Push current focus onto stack (for modals/dialogs) ---- Saves current focus and sets new focus to the given element ----@param element Element? The element to focus (e.g., modal dialog) -function KeyboardNavigation:pushFocus(element) - local Context = KeyboardNavigation._Context - - table.insert(KeyboardNavigation._navigationStack, Context.getFocused()) - Context.pushFocusStack(element) -end - ---- Pop focus from stack (return from modal) ---- Restores previously focused element from the stack ----@return Element? The previously focused element, or nil if stack was empty -function KeyboardNavigation:popFocus() - local Context = KeyboardNavigation._Context - - local previous = Context.popFocusStack() - if #KeyboardNavigation._navigationStack > 0 then - previous = table.remove(KeyboardNavigation._navigationStack) - end - - return previous -end - --- ==================== --- Spatial Index (Performance Optimization) --- ==================== - ---- Enable spatial index for faster directional navigation ----@param enabled boolean -function KeyboardNavigation.enableSpatialIndex(enabled) - KeyboardNavigation._spatialIndex.enabled = enabled - if not enabled then - KeyboardNavigation:_clearSpatialIndex() - end -end - ---- Clear spatial index -function KeyboardNavigation:_clearSpatialIndex() - KeyboardNavigation._spatialIndex.grid = {} - KeyboardNavigation._spatialIndex.elementPositions = {} -end - ---- Find directional neighbor using spatial index ----@param current Element ----@param direction "up"|"down"|"left"|"right" ----@return Element? -function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction) - local index = KeyboardNavigation._spatialIndex - local cellSize = index.cellSize - - -- Get current element's grid position - local currentPos = index.elementPositions[current] - if not currentPos then - return nil - end - - local centerX = currentPos.x + currentPos.w / 2 - local centerY = currentPos.y + currentPos.h / 2 - local currentCellX = math.floor(centerX / cellSize) - local currentCellY = math.floor(centerY / cellSize) - - -- Search in direction, expanding outward - local maxSearchRadius = 20 -- Maximum cells to search - local visited = {} - - for radius = 1, maxSearchRadius do - local candidates = {} - - -- Get cells in the search ring - if direction == "up" then - table.insert(candidates, { currentCellX, currentCellY - radius }) - if radius > 1 then - table.insert(candidates, { currentCellX - 1, currentCellY - radius }) - table.insert(candidates, { currentCellX + 1, currentCellY - radius }) - end - elseif direction == "down" then - table.insert(candidates, { currentCellX, currentCellY + radius }) - if radius > 1 then - table.insert(candidates, { currentCellX - 1, currentCellY + radius }) - table.insert(candidates, { currentCellX + 1, currentCellY + radius }) - end - elseif direction == "left" then - table.insert(candidates, { currentCellX - radius, currentCellY }) - if radius > 1 then - table.insert(candidates, { currentCellX - radius, currentCellY - 1 }) - table.insert(candidates, { currentCellX - radius, currentCellY + 1 }) - end - elseif direction == "right" then - table.insert(candidates, { currentCellX + radius, currentCellY }) - if radius > 1 then - table.insert(candidates, { currentCellX + radius, currentCellY - 1 }) - table.insert(candidates, { currentCellX + radius, currentCellY + 1 }) - end - end - - -- Check each candidate cell - for _, cell in ipairs(candidates) do - local cellKey = string.format("%d,%d", cell[1], cell[2]) - local cellElements = index.grid[cellKey] - - if cellElements then - for _, elem in ipairs(cellElements) do - if elem ~= current and not visited[elem] then - visited[elem] = true - local elemPos = index.elementPositions[elem] - if elemPos then - local elemCenterX = elemPos.x + elemPos.w / 2 - local elemCenterY = elemPos.y + elemPos.h / 2 - - -- Check if element is in the correct direction - local isInDirection = false - if direction == "up" and elemCenterY < centerY then - isInDirection = true - elseif direction == "down" and elemCenterY > centerY then - isInDirection = true - elseif direction == "left" and elemCenterX < centerX then - isInDirection = true - elseif direction == "right" and elemCenterX > centerX then - isInDirection = true - end - - if isInDirection then - return elem - end - end - end - end - end - end - end - - return nil -end - -return KeyboardNavigation diff --git a/libs/flexlove/modules/LayoutEngine.lua b/libs/flexlove/modules/LayoutEngine.lua deleted file mode 100644 index cf4dec4d..00000000 --- a/libs/flexlove/modules/LayoutEngine.lua +++ /dev/null @@ -1,1714 +0,0 @@ ----@class LayoutEngine ----@field element Element? Reference to the parent element ----@field positioning Positioning Layout positioning mode ----@field flexDirection FlexDirection Direction of flex layout ----@field justifyContent JustifyContent Alignment of items along main axis ----@field alignItems AlignItems Alignment of items along cross axis ----@field alignContent AlignContent Alignment of lines in multi-line flex containers ----@field flexWrap FlexWrap Whether children wrap to multiple lines ----@field gap number Space between children elements ----@field gridRows number? Number of rows in the grid ----@field gridColumns number? Number of columns in the grid ----@field columnGap number? Gap between grid columns ----@field rowGap number? Gap between grid rows ----@field _Grid table ----@field _Units table ----@field _Context table ----@field _Positioning table ----@field _FlexDirection table ----@field _JustifyContent table ----@field _AlignContent table ----@field _AlignItems table ----@field _AlignSelf table ----@field _FlexWrap table ----@field _layoutCount number Track layout recalculations per frame ----@field _lastFrameCount number Last frame number for resetting counters ----@field _ErrorHandler ErrorHandler? ErrorHandler module dependency ----@field _Performance Performance? Performance module dependency -local LayoutEngine = {} -LayoutEngine.__index = LayoutEngine - ---- Recursively shift an element and all its descendants by (dx, dy). ---- Used by the row-reverse mirror pass and the `position: relative` offset ---- pass: both run after the rest of layout has placed the subtree, so a single ---- delta walk keeps descendants visually anchored to the parent. ----@param elem Element ----@param dx number ----@param dy number -local function shiftSubtree(elem, dx, dy) - elem.x = elem.x + dx - elem.y = elem.y + dy - for _, c in ipairs(elem.children) do - shiftSubtree(c, dx, dy) - end -end - ---- Initialize module with shared dependencies ----@param deps table Dependencies {ErrorHandler, Performance, utils} -function LayoutEngine.init(deps) - LayoutEngine._ErrorHandler = deps.ErrorHandler - LayoutEngine._Performance = deps.Performance - LayoutEngine._Utils = deps.utils -end - ----@class LayoutEngineProps ----@field positioning Positioning? Layout positioning mode (default: RELATIVE) ----@field flexDirection FlexDirection? Direction of flex layout (default: HORIZONTAL) ----@field justifyContent JustifyContent? Alignment of items along main axis (default: FLEX_START) ----@field alignItems AlignItems? Alignment of items along cross axis (default: STRETCH) ----@field alignContent AlignContent? Alignment of lines in multi-line flex containers (default: STRETCH) ----@field flexWrap FlexWrap? Whether children wrap to multiple lines (default: NOWRAP) ----@field gap number? Space between children elements (default: 10) ----@field gridRows number? Number of rows in the grid ----@field gridColumns number? Number of columns in the grid ----@field columnGap number? Gap between grid columns ----@field rowGap number? Gap between grid rows - ---- Create a new LayoutEngine instance ----@param props LayoutEngineProps ----@param deps table Dependencies {utils, Grid, Units, Context} ----@return LayoutEngine -function LayoutEngine.new(props, deps) - local enums = deps.utils.enums - local Positioning = enums.Positioning - local FlexDirection = enums.FlexDirection - local JustifyContent = enums.JustifyContent - local AlignContent = enums.AlignContent - local AlignItems = enums.AlignItems - local AlignSelf = enums.AlignSelf - local FlexWrap = enums.FlexWrap - - local self = setmetatable({}, LayoutEngine) - - -- Store dependencies for instance methods - self._Grid = deps.Grid - self._Units = deps.Units - self._Context = deps.Context - self._ErrorHandler = deps.ErrorHandler - self._Positioning = Positioning - self._FlexDirection = FlexDirection - self._JustifyContent = JustifyContent - self._AlignContent = AlignContent - self._AlignItems = AlignItems - self._AlignSelf = AlignSelf - self._FlexWrap = FlexWrap - - -- Layout configuration - self.positioning = props.positioning or Positioning.FLEX - self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL - self.justifyContent = props.justifyContent or JustifyContent.FLEX_START - self.alignItems = props.alignItems or AlignItems.STRETCH - self.alignContent = props.alignContent or AlignContent.STRETCH - self.flexWrap = props.flexWrap or FlexWrap.NOWRAP - self.gap = props.gap or 10 - - -- Grid layout configuration - self.gridRows = props.gridRows - self.gridColumns = props.gridColumns - - self.columnGap = props.columnGap - self.rowGap = props.rowGap - - -- Element reference (will be set via initialize) - self.element = nil - - -- Performance tracking - self._layoutCount = 0 - self._lastFrameCount = 0 - - -- Layout memoization cache - self._layoutCache = { - childrenCount = 0, - containerWidth = 0, - containerHeight = 0, - containerX = 0, - containerY = 0, - childrenHash = "", - } - - return self -end - ---- Initialize the LayoutEngine with its parent element ----@param element Element The parent element -function LayoutEngine:initialize(element) - self.element = element -end - ---- True for flex-direction `horizontal` or `horizontal-reverse` (and their ---- `row`/`row-reverse` aliases, which normalize to those at construction). ---- Routes every main-axis orientation check so reverse directions are ---- correctly classified as horizontal. ----@return boolean -function LayoutEngine:_isHorizontal() - return self.flexDirection == self._FlexDirection.HORIZONTAL - or self.flexDirection == self._FlexDirection.HORIZONTAL_REVERSE -end - ---- True for flex-direction `horizontal-reverse` or `vertical-reverse` ---- (and their `row-reverse`/`column-reverse` aliases). ----@return boolean -function LayoutEngine:_isReverse() - return self.flexDirection == self._FlexDirection.HORIZONTAL_REVERSE - or self.flexDirection == self._FlexDirection.VERTICAL_REVERSE -end - ---- Apply CSS positioning offsets (top, right, bottom, left) to a child element ----@param child Element The element to apply offsets to -function LayoutEngine:applyPositioningOffsets(child) - if not child then - return - end - - -- For CSS-style positioning, we need the parent's bounds - local parent = child.parent - if not parent then - return - end - - -- Only apply offsets to explicitly absolute children or children in relative/absolute containers - -- Flex/grid children ignore positioning offsets as they participate in layout - local isFlexChild = child.positioning == self._Positioning.FLEX - or child.positioning == self._Positioning.GRID - or (child.positioning == self._Positioning.ABSOLUTE and not child._explicitlyAbsolute) - - if not isFlexChild and child._explicitlyAbsolute then - -- Apply absolute positioning for explicitly absolute children - -- Apply top offset (distance from parent's content box top edge) - if child.top then - child.y = parent.y + parent.padding.top + child.top - end - - -- Apply bottom offset (distance from parent's content box bottom edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if child.bottom then - local elementBorderBoxHeight = child:getBorderBoxHeight() - child.y = parent.y + parent.padding.top + parent.height - child.bottom - elementBorderBoxHeight - end - - -- Apply left offset (distance from parent's content box left edge) - if child.left then - child.x = parent.x + parent.padding.left + child.left - end - - -- Apply right offset (distance from parent's content box right edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if child.right then - local elementBorderBoxWidth = child:getBorderBoxWidth() - child.x = parent.x + parent.padding.left + parent.width - child.right - elementBorderBoxWidth - end - end -end - ---- Calculate flex item sizes based on flexGrow, flexShrink, flexBasis ---- Implements CSS flexbox sizing algorithm ----@param children table Array of child elements in the flex line ----@param availableMainSize number Available space in main axis ----@param gap number Gap between items ----@param isHorizontal boolean Whether main axis is horizontal ----@param defaultFlexShrink number? Default flex-shrink to use when child.flexShrink is nil ----@return table mainSizes Array of calculated main sizes for each child -function LayoutEngine:_calculateFlexSizes(children, availableMainSize, gap, isHorizontal, defaultFlexShrink) - local implicitFlexShrink = defaultFlexShrink - if implicitFlexShrink == nil then - implicitFlexShrink = 1 - end - - local function getResolvedFlexShrink(child) - if child._hasExplicitFlexShrink then - return child.flexShrink - end - return implicitFlexShrink - end - - local childCount = #children - local totalGaps = math.max(0, childCount - 1) * gap - local availableForContent = availableMainSize - totalGaps - local viewportWidth, viewportHeight = self._Units.getViewport() - - -- Step 1: Calculate hypothetical main sizes (flex basis resolution) - local hypotheticalSizes = {} - local flexBases = {} - local totalFlexBasis = 0 - - local function resolveDeclaredMainSize(child) - local axisUnits = nil - if child.units then - axisUnits = isHorizontal and child.units.width or child.units.height - end - - if not axisUnits or axisUnits.unit == "auto" or axisUnits.value == nil then - return nil - end - - local resolved = - self._Units.resolve(axisUnits.value, axisUnits.unit, viewportWidth, viewportHeight, availableMainSize) - - if type(resolved) == "number" then - return math.max(0, resolved) - end - - return nil - end - - for i, child in ipairs(children) do - local flexBasis = child.flexBasis - local hypotheticalSize - - -- Resolve flex-basis - if flexBasis == "auto" then - -- Use declared main size to avoid reusing a previously flexed runtime size - hypotheticalSize = resolveDeclaredMainSize(child) - if hypotheticalSize == nil then - if isHorizontal then - hypotheticalSize = child:getBorderBoxWidth() - else - hypotheticalSize = child:getBorderBoxHeight() - end - end - elseif type(flexBasis) == "number" then - hypotheticalSize = flexBasis - elseif type(flexBasis) == "string" and child.units.flexBasis then - -- Parse and resolve flex-basis with units - local value, unit = child.units.flexBasis.value, child.units.flexBasis.unit - hypotheticalSize = self._Units.resolve(value, unit, viewportWidth, viewportHeight, availableMainSize) - else - -- Fallback to element's natural size - if isHorizontal then - hypotheticalSize = child:getBorderBoxWidth() - else - hypotheticalSize = child:getBorderBoxHeight() - end - end - - -- Add margins to hypothetical size - local childMargin = child.margin - if isHorizontal then - hypotheticalSize = hypotheticalSize + childMargin.left + childMargin.right - else - hypotheticalSize = hypotheticalSize + childMargin.top + childMargin.bottom - end - - flexBases[i] = hypotheticalSize - hypotheticalSizes[i] = hypotheticalSize - totalFlexBasis = totalFlexBasis + hypotheticalSize - end - - -- Step 2: Determine if we need to grow or shrink - local freeSpace = availableForContent - totalFlexBasis - - -- Step 3a: Handle positive free space (GROW) - if freeSpace > 0 then - local totalFlexGrow = 0 - for _, child in ipairs(children) do - totalFlexGrow = totalFlexGrow + (child.flexGrow or 0) - end - - if totalFlexGrow > 0 then - -- Distribute free space proportionally to flex-grow values - for i, child in ipairs(children) do - local flexGrow = child.flexGrow or 0 - if flexGrow > 0 then - local growAmount = (flexGrow / totalFlexGrow) * freeSpace - hypotheticalSizes[i] = hypotheticalSizes[i] + growAmount - end - end - end - -- Step 3b: Handle negative free space (SHRINK) - elseif freeSpace < 0 then - local totalFlexShrink = 0 - local totalScaledShrinkFactor = 0 - - for i, child in ipairs(children) do - local flexShrink = getResolvedFlexShrink(child) - totalFlexShrink = totalFlexShrink + flexShrink - -- Scaled shrink factor = flex-shrink × flex-basis - totalScaledShrinkFactor = totalScaledShrinkFactor + (flexShrink * flexBases[i]) - end - - if totalScaledShrinkFactor > 0 then - -- Distribute shrinkage proportionally to (flex-shrink × flex-basis) - for i, child in ipairs(children) do - local flexShrink = getResolvedFlexShrink(child) - if flexShrink > 0 then - local scaledShrinkFactor = flexShrink * flexBases[i] - local shrinkAmount = (scaledShrinkFactor / totalScaledShrinkFactor) * math.abs(freeSpace) - hypotheticalSizes[i] = math.max(0, hypotheticalSizes[i] - shrinkAmount) - end - end - end - end - - -- Step 4: Return final main sizes (excluding margins), clamped to per-child min/max - local mainSizes = {} - for i, child in ipairs(children) do - local childMargin = child.margin - local marginSum = isHorizontal and (childMargin.left + childMargin.right) or (childMargin.top + childMargin.bottom) - local minBound = isHorizontal and child.minWidth or child.minHeight - local maxBound = isHorizontal and child.maxWidth or child.maxHeight - mainSizes[i] = LayoutEngine._Utils.clamp(math.max(0, hypotheticalSizes[i] - marginSum), minBound, maxBound) - end - - return mainSizes -end - ---- Layout children within this element according to positioning mode -function LayoutEngine:layoutChildren() - -- Start performance timing first (before any early returns) - local timerName = nil - if LayoutEngine._Performance and LayoutEngine._Performance.enabled and self.element then - -- Use memory address to make timer name unique per element instance - timerName = "layout_" .. (self.element.id or tostring(self.element):match("0x%x+") or "unknown") - LayoutEngine._Performance:startTimer(timerName) - end - - if self.element == nil then - return - end - - -- Check if layout can be skipped (memoization optimization) - if self:_canSkipLayout() then - if timerName and LayoutEngine._Performance then - LayoutEngine._Performance:stopTimer(timerName) - end - return - end - - -- Track layout recalculations for performance warnings - self:_trackLayoutRecalculation() - - -- Handle grid layout - if self.positioning == self._Positioning.GRID then - self._Grid.layoutGridItems(self.element) - - -- Stop performance timing - if timerName and LayoutEngine._Performance then - LayoutEngine._Performance:stopTimer(timerName) - end - return - end - - local childCount = #self.element.children - - if childCount == 0 then - -- Stop performance timing - if timerName and LayoutEngine._Performance then - LayoutEngine._Performance:stopTimer(timerName) - end - return - end - - -- Get flex children (children that participate in flex layout) - -- Exclude display=false (CSS display:none) and explicitly absolute children - local flexChildren = {} - for _, child in ipairs(self.element.children) do - local isFlexChild = not (child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute) - and child.display ~= false - if isFlexChild then - table.insert(flexChildren, child) - - -- Warn if child uses percentage sizing but parent has autosizing - if child.units and child.units.width then - if child.units.width.unit == "%" and self.element.autosizing and self.element.autosizing.width then - self.element:_warnIfPercentageWithAutoSizing(child, "width") - end - end - if child.units and child.units.height then - if child.units.height.unit == "%" and self.element.autosizing and self.element.autosizing.height then - self.element:_warnIfPercentageWithAutoSizing(child, "height") - end - end - end - end - - -- CSS-compliant behavior: absolutely positioned elements are completely removed from normal flow - -- They do NOT reserve space or affect flex layout calculations at all - - -- If no flex children, skip flex layout but still position absolute children - if #flexChildren == 0 then - -- Position absolutely positioned children even when there are no flex children - for i, child in ipairs(self.element.children) do - if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then - self:applyPositioningOffsets(child) - - -- If child has children, layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - end - end - - -- Detect overflow after children positioning - if self.element._detectOverflow then - self.element:_detectOverflow() - end - - -- Stop performance timing - if timerName and LayoutEngine._Performance then - LayoutEngine._Performance:stopTimer(timerName) - end - return - end - - -- Calculate available space (accounting for padding only, NOT absolute children) - -- BORDER-BOX MODEL: element.width and element.height are already content dimensions (padding subtracted) - local availableMainSize = 0 - local availableCrossSize = 0 - - -- Reserve space for scrollbars if needed (reserve-space mode) - local scrollbarReservedWidth = 0 - local scrollbarReservedHeight = 0 - if self.element._scrollManager and self.element._scrollManager.scrollbarPlacement == "reserve-space" then - scrollbarReservedWidth, scrollbarReservedHeight = self.element._scrollManager:getReservedSpace(self.element) - end - - if self:_isHorizontal() then - availableMainSize = self.element.width - scrollbarReservedWidth - availableCrossSize = self.element.height - scrollbarReservedHeight - else - availableMainSize = self.element.height - scrollbarReservedHeight - availableCrossSize = self.element.width - scrollbarReservedWidth - end - - -- Keep percentage-sized children in sync when container dimensions change. - -- Managed select frames rely on this so `width = "100%"` options expand with the dropdown. - if scrollbarReservedWidth > 0 or scrollbarReservedHeight > 0 or self.element:_shouldSyncPercentageDimensions() then - local isHorizontal = self:_isHorizontal() - for _, child in ipairs(flexChildren) do - if isHorizontal then - -- Horizontal flex: main-axis is width, cross-axis is height - -- Adjust main-axis width if percentage-based - if child.units and child.units.width and child.units.width.unit == "%" then - local newBorderBoxWidth = LayoutEngine._Utils.clamp( - (child.units.width.value / 100) * availableMainSize, - child.minWidth, - child.maxWidth - ) - child._borderBoxWidth = newBorderBoxWidth - child.width = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) - end - -- Adjust cross-axis height if percentage-based - if child.units and child.units.height and child.units.height.unit == "%" then - local newBorderBoxHeight = LayoutEngine._Utils.clamp( - (child.units.height.value / 100) * availableCrossSize, - child.minHeight, - child.maxHeight - ) - child._borderBoxHeight = newBorderBoxHeight - child.height = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) - end - else - -- Vertical flex: main-axis is height, cross-axis is width - -- Adjust main-axis height if percentage-based - if child.units and child.units.height and child.units.height.unit == "%" then - local newBorderBoxHeight = LayoutEngine._Utils.clamp( - (child.units.height.value / 100) * availableMainSize, - child.minHeight, - child.maxHeight - ) - child._borderBoxHeight = newBorderBoxHeight - child.height = math.max(0, newBorderBoxHeight - child.padding.top - child.padding.bottom) - end - -- Adjust cross-axis width if percentage-based - if child.units and child.units.width and child.units.width.unit == "%" then - local rawBorderBoxWidth = (child.units.width.value / 100) * availableCrossSize - local newBorderBoxWidth = LayoutEngine._Utils.clamp( - self.element:_adjustCrossAxisPercentageWidth(child, rawBorderBoxWidth), - child.minWidth, - child.maxWidth - ) - child._borderBoxWidth = newBorderBoxWidth - child.width = math.max(0, newBorderBoxWidth - child.padding.left - child.padding.right) - end - end - end - end - - -- Handle flex wrap: create lines of children - local lines = {} - - if self.flexWrap == self._FlexWrap.NOWRAP then - -- All children go on one line - lines[1] = flexChildren - else - -- Wrap children into multiple lines - local currentLine = {} - local currentLineSize = 0 - - -- Performance optimization: hoist enum comparisons outside loop - local isHorizontal = self:_isHorizontal() - local gapSize = self.gap - local viewportWidth, viewportHeight = self._Units.getViewport() - - local function resolveDeclaredMainSizeForWrap(child) - local axisUnits = nil - if child.units then - axisUnits = isHorizontal and child.units.width or child.units.height - end - - if not axisUnits or axisUnits.unit == "auto" or axisUnits.value == nil then - return nil - end - - local resolved = - self._Units.resolve(axisUnits.value, axisUnits.unit, viewportWidth, viewportHeight, availableMainSize) - - if type(resolved) == "number" then - return math.max(0, resolved) - end - - return nil - end - - for _, child in ipairs(flexChildren) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in size calculations - -- Performance optimization: hoist margin table access - local childMargin = child.margin - local childMainSize = 0 - local childMainMargin = 0 - local declaredMainSize = resolveDeclaredMainSizeForWrap(child) - if isHorizontal then - childMainSize = declaredMainSize or child:getBorderBoxWidth() - childMainMargin = childMargin.left + childMargin.right - else - childMainSize = declaredMainSize or child:getBorderBoxHeight() - childMainMargin = childMargin.top + childMargin.bottom - end - local childTotalMainSize = childMainSize + childMainMargin - - -- Check if adding this child would exceed the available space - local lineSpacing = #currentLine > 0 and gapSize or 0 - if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then - -- Start a new line - if #currentLine > 0 then - table.insert(lines, currentLine) - end - currentLine = { child } - currentLineSize = childTotalMainSize - else - -- Add to current line - table.insert(currentLine, child) - currentLineSize = currentLineSize + lineSpacing + childTotalMainSize - end - end - - -- Add the last line if it has children - if #currentLine > 0 then - table.insert(lines, currentLine) - end - - -- Handle wrap-reverse: reverse the order of lines - if self.flexWrap == self._FlexWrap.WRAP_REVERSE then - local reversedLines = {} - for i = #lines, 1, -1 do - table.insert(reversedLines, lines[i]) - end - lines = reversedLines - end - end - - -- Apply flex sizing to each line BEFORE calculating line heights - -- Performance optimization: hoist enum comparison outside loop - local isHorizontal = self:_isHorizontal() - local mainAxisOverflow = nil - if self:_isHorizontal() then - mainAxisOverflow = self.element.overflowX or self.element.overflow - else - mainAxisOverflow = self.element.overflowY or self.element.overflow - end - local preserveMainAxisOverflow = (mainAxisOverflow == "scroll" or mainAxisOverflow == "auto") - local defaultFlexShrink = preserveMainAxisOverflow and 0 or 1 - - for lineIndex, line in ipairs(lines) do - -- Check if any child in this line needs flex sizing. - -- For scroll/auto in the main axis, keep implicit shrink at 0 so overflow can scroll. - local needsFlexSizing = false - for _, child in ipairs(line) do - local flexGrow = child.flexGrow or 0 - local flexBasis = child.flexBasis - local resolvedFlexShrink = defaultFlexShrink - if child._hasExplicitFlexShrink then - resolvedFlexShrink = child.flexShrink - end - - if flexGrow > 0 or (flexBasis and flexBasis ~= "auto") or resolvedFlexShrink > 0 then - needsFlexSizing = true - break - end - end - - -- Only apply flex sizing if needed - if needsFlexSizing then - -- Calculate flex sizes for this line - local mainSizes = self:_calculateFlexSizes(line, availableMainSize, self.gap, isHorizontal, defaultFlexShrink) - - -- Apply calculated sizes to children - for i, child in ipairs(line) do - local mainSize = mainSizes[i] - - if isHorizontal then - -- Update width for horizontal flex - child._borderBoxWidth = mainSize - child.width = math.max(0, mainSize - child.padding.left - child.padding.right) - -- Invalidate width cache - child._borderBoxWidthCache = nil - else - -- Update height for vertical flex - child._borderBoxHeight = mainSize - child.height = math.max(0, mainSize - child.padding.top - child.padding.bottom) - -- Invalidate height cache - child._borderBoxHeightCache = nil - end - - -- Trigger layout for child's children if any - if #child.children > 0 then - child:layoutChildren() - end - end - end - end - - -- Calculate line positions and heights (including child padding) - -- Performance optimization: preallocate array if possible - local lineHeights = table.create and table.create(#lines) or {} - local totalLinesHeight = 0 - - -- Performance optimization: hoist enum comparison outside loop (already hoisted above) - -- local isHorizontal = self.flexDirection == self._FlexDirection.HORIZONTAL - - for lineIndex, line in ipairs(lines) do - local maxCrossSize = 0 - for _, child in ipairs(line) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in cross-axis size calculations - -- Performance optimization: hoist margin table access - local childMargin = child.margin - local childCrossSize = 0 - local childCrossMargin = 0 - if isHorizontal then - childCrossSize = child:getBorderBoxHeight() - childCrossMargin = childMargin.top + childMargin.bottom - else - childCrossSize = child:getBorderBoxWidth() - childCrossMargin = childMargin.left + childMargin.right - end - local childTotalCrossSize = childCrossSize + childCrossMargin - maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) - end - lineHeights[lineIndex] = maxCrossSize - totalLinesHeight = totalLinesHeight + maxCrossSize - end - - -- Account for gaps between lines - local lineGaps = math.max(0, #lines - 1) * self.gap - totalLinesHeight = totalLinesHeight + lineGaps - - -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size - if #lines == 1 then - if - self.alignItems == self._AlignItems.STRETCH - or self.alignItems == self._AlignItems.CENTER - or self.alignItems == self._AlignItems.FLEX_END - then - -- STRETCH, CENTER, and FLEX_END should use full available cross size - lineHeights[1] = availableCrossSize - totalLinesHeight = availableCrossSize - end - -- CENTER and FLEX_END should preserve natural child dimensions - -- and only affect positioning within the available space - end - - -- Calculate starting position for lines based on alignContent - local lineStartPos = 0 - local lineSpacing = self.gap - local freeLineSpace = availableCrossSize - totalLinesHeight - - -- Apply AlignContent logic for both single and multiple lines - if self.alignContent == self._AlignContent.FLEX_START then - lineStartPos = 0 - elseif self.alignContent == self._AlignContent.CENTER then - lineStartPos = freeLineSpace / 2 - elseif self.alignContent == self._AlignContent.FLEX_END then - lineStartPos = freeLineSpace - elseif self.alignContent == self._AlignContent.SPACE_BETWEEN then - lineStartPos = 0 - if #lines > 1 then - lineSpacing = self.gap + (freeLineSpace / (#lines - 1)) - end - elseif self.alignContent == self._AlignContent.SPACE_AROUND then - local spaceAroundEach = freeLineSpace / #lines - lineStartPos = spaceAroundEach / 2 - lineSpacing = self.gap + spaceAroundEach - elseif self.alignContent == self._AlignContent.STRETCH then - lineStartPos = 0 - if #lines > 1 and freeLineSpace > 0 then - lineSpacing = self.gap + (freeLineSpace / #lines) - -- Distribute extra space to line heights (only if positive) - local extraPerLine = freeLineSpace / #lines - for i = 1, #lineHeights do - lineHeights[i] = lineHeights[i] + extraPerLine - end - end - end - - -- Position children within each line - local currentCrossPos = lineStartPos - - for lineIndex, line in ipairs(lines) do - local lineHeight = lineHeights[lineIndex] - - -- Calculate total size of children in this line (including padding and margins) - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Performance optimization: hoist flexDirection check outside loop - local isHorizontal = self:_isHorizontal() - local totalChildrenSize = 0 - for _, child in ipairs(line) do - local childMargin = child.margin - if isHorizontal then - totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + childMargin.left + childMargin.right - else - totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + childMargin.top + childMargin.bottom - end - end - - local totalGapSize = math.max(0, #line - 1) * self.gap - local totalContentSize = totalChildrenSize + totalGapSize - local freeSpace = availableMainSize - totalContentSize - - -- Calculate initial position and spacing based on justifyContent - local startPos = 0 - local itemSpacing = self.gap - - if self.justifyContent == self._JustifyContent.FLEX_START then - startPos = 0 - elseif self.justifyContent == self._JustifyContent.CENTER then - startPos = math.max(0, freeSpace / 2) - elseif self.justifyContent == self._JustifyContent.FLEX_END then - startPos = math.max(0, freeSpace) - elseif self.justifyContent == self._JustifyContent.SPACE_BETWEEN then - startPos = 0 - if #line > 1 and freeSpace > 0 then - itemSpacing = self.gap + (freeSpace / (#line - 1)) - end - elseif self.justifyContent == self._JustifyContent.SPACE_AROUND then - if freeSpace > 0 then - local spaceAroundEach = freeSpace / #line - startPos = spaceAroundEach / 2 - itemSpacing = self.gap + spaceAroundEach - end - elseif self.justifyContent == self._JustifyContent.SPACE_EVENLY then - if freeSpace > 0 then - local spaceBetween = freeSpace / (#line + 1) - startPos = spaceBetween - itemSpacing = self.gap + spaceBetween - end - end - - -- Position children in this line - local currentMainPos = startPos - - -- Performance optimization: hoist frequently accessed element properties - local elementX = self.element.x - local elementY = self.element.y - local elementPadding = self.element.padding - local elementPaddingLeft = elementPadding.left - local elementPaddingTop = elementPadding.top - local alignItems = self.alignItems - local alignSelf_AUTO = self._AlignSelf.AUTO - local alignItems_FLEX_START = self._AlignItems.FLEX_START - local alignItems_CENTER = self._AlignItems.CENTER - local alignItems_FLEX_END = self._AlignItems.FLEX_END - local alignItems_STRETCH = self._AlignItems.STRETCH - - for _, child in ipairs(line) do - -- Performance optimization: hoist child table accesses - local childMargin = child.margin - local childPadding = child.padding - local childAutosizing = child.autosizing - - -- Determine effective cross-axis alignment - local effectiveAlign = child.alignSelf - if effectiveAlign == nil or effectiveAlign == alignSelf_AUTO then - effectiveAlign = alignItems - end - - if self:_isHorizontal() then - -- Horizontal layout: main axis is X, cross axis is Y - -- Position child at border box (x, y represents top-left including padding) - -- CSS-compliant: absolute children don't affect flex positioning, so no reserved space offset - local childMarginLeft = childMargin.left - child.x = elementX + elementPaddingLeft + currentMainPos + childMarginLeft - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxHeight = child:getBorderBoxHeight() - local childMarginTop = childMargin.top - local childMarginBottom = childMargin.bottom - local childTotalCrossSize = childBorderBoxHeight + childMarginTop + childMarginBottom - - if effectiveAlign == alignItems_FLEX_START then - child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop - elseif effectiveAlign == alignItems_CENTER then - child.y = elementY - + elementPaddingTop - + currentCrossPos - + ((lineHeight - childTotalCrossSize) / 2) - + childMarginTop - elseif effectiveAlign == alignItems_FLEX_END then - child.y = elementY + elementPaddingTop + currentCrossPos + lineHeight - childTotalCrossSize + childMarginTop - elseif effectiveAlign == alignItems_STRETCH then - -- STRETCH: Only apply if height was not explicitly set - if childAutosizing and childAutosizing.height then - -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit - local availableHeight = LayoutEngine._Utils.clamp( - lineHeight - childMarginTop - childMarginBottom, - child.minHeight, - child.maxHeight - ) - child._borderBoxHeight = availableHeight - child.height = math.max(0, availableHeight - childPadding.top - childPadding.bottom) - end - child.y = elementY + elementPaddingTop + currentCrossPos + childMarginTop - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box width plus margins - currentMainPos = currentMainPos + child:getBorderBoxWidth() + childMarginLeft + childMargin.right + itemSpacing - else - -- Vertical layout: main axis is Y, cross axis is X - -- Position child at border box (x, y represents top-left including padding) - -- CSS-compliant: absolute children don't affect flex positioning, so no reserved space offset - local childMarginTop = childMargin.top - child.y = elementY + elementPaddingTop + currentMainPos + childMarginTop - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childMarginLeft = childMargin.left - local childMarginRight = childMargin.right - local childTotalCrossSize = childBorderBoxWidth + childMarginLeft + childMarginRight - local elementPaddingLeft = elementPadding.left - - if effectiveAlign == alignItems_FLEX_START then - child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft - elseif effectiveAlign == alignItems_CENTER then - child.x = elementX - + elementPaddingLeft - + currentCrossPos - + ((lineHeight - childTotalCrossSize) / 2) - + childMarginLeft - elseif effectiveAlign == alignItems_FLEX_END then - child.x = elementX + elementPaddingLeft + currentCrossPos + lineHeight - childTotalCrossSize + childMarginLeft - elseif effectiveAlign == alignItems_STRETCH then - -- STRETCH: Only apply if width was not explicitly set - if childAutosizing and childAutosizing.width then - -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit - local availableWidth = - LayoutEngine._Utils.clamp(lineHeight - childMarginLeft - childMarginRight, child.minWidth, child.maxWidth) - child._borderBoxWidth = availableWidth - child.width = math.max(0, availableWidth - childPadding.left - childPadding.right) - end - child.x = elementX + elementPaddingLeft + currentCrossPos + childMarginLeft - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box height plus margins - currentMainPos = currentMainPos - + child:getBorderBoxHeight() - + child.margin.top - + child.margin.bottom - + itemSpacing - end - end - - -- Move to next line position - currentCrossPos = currentCrossPos + lineHeight + lineSpacing - end - - -- Position explicitly absolute children after flex layout - for i, child in ipairs(self.element.children) do - if child.positioning == self._Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - end - end - - -- flex-direction: row-reverse / column-reverse — mirror the main-axis - -- position of each flex child relative to the container content area, and - -- shift the child's subtree by the same delta so descendants follow. - -- Cross-axis positions and absolute children are not affected. - if self:_isReverse() then - local parent = self.element - local padLeft = parent.padding.left - local padTop = parent.padding.top - local contentW = parent.width - local contentH = parent.height - local mirrorHorizontal = self:_isHorizontal() - - for _, child in ipairs(flexChildren) do - if mirrorHorizontal then - local distFromLeft = child.x - parent.x - padLeft - local childW = child:getBorderBoxWidth() - local newDistFromLeft = contentW - distFromLeft - childW - local dx = newDistFromLeft - distFromLeft - if dx ~= 0 then - shiftSubtree(child, dx, 0) - end - else - local distFromTop = child.y - parent.y - padTop - local childH = child:getBorderBoxHeight() - local newDistFromTop = contentH - distFromTop - childH - local dy = newDistFromTop - distFromTop - if dy ~= 0 then - shiftSubtree(child, 0, dy) - end - end - end - end - - -- position: relative — shift each in-flow child by (left or -right, - -- top or -bottom) after the flex flow (and row-reverse mirroring) has - -- placed it, so the offset is a pure visual delta that doesn't influence - -- siblings' flow positions. Per CSS, `top` wins over `bottom` and `left` - -- over `right` when both are set. Static/absolute children are unaffected - -- (absolute uses applyPositioningOffsets; flex-participating children - -- dropped the offsets and emitted LAY_011 at construction). Runs for every - -- container type so relative children in relative containers also honor offsets. - for _, child in ipairs(self.element.children) do - if child.positioning == self._Positioning.RELATIVE and child.display ~= false then - local dx, dy = 0, 0 - if child.top then - dy = child.top - elseif child.bottom then - dy = -child.bottom - end - if child.left then - dx = child.left - elseif child.right then - dx = -child.right - end - if dx ~= 0 or dy ~= 0 then - shiftSubtree(child, dx, dy) - end - end - end - - -- Detect overflow after children are laid out - if self.element._detectOverflow then - self.element:_detectOverflow() - end - - -- Stop performance timing - if timerName and LayoutEngine._Performance then - LayoutEngine._Performance:stopTimer(timerName) - end -end - ---- Simulate wrapping children into lines for auto-sizing calculations ----@param children table Array of child elements ----@param availableSize number Available space in main axis ----@param isHorizontal boolean True if flex direction is horizontal ----@return table Array of lines, where each line is an array of children -function LayoutEngine:_simulateWrap(children, availableSize, isHorizontal) - local lines = {} - local currentLine = {} - local currentLineSize = 0 - - for _, child in ipairs(children) do - -- Calculate child size in main axis (including margins) - local childMainSize = 0 - local childMainMargin = 0 - if isHorizontal then - childMainSize = child:getBorderBoxWidth() - if child.margin then - childMainMargin = child.margin.left + child.margin.right - end - else - childMainSize = child:getBorderBoxHeight() - if child.margin then - childMainMargin = child.margin.top + child.margin.bottom - end - end - local childTotalMainSize = childMainSize + childMainMargin - - -- Check if adding this child would exceed the available space - local lineSpacing = #currentLine > 0 and self.gap or 0 - if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableSize then - -- Start a new line - table.insert(lines, currentLine) - currentLine = { child } - currentLineSize = childTotalMainSize - else - -- Add to current line - table.insert(currentLine, child) - currentLineSize = currentLineSize + lineSpacing + childTotalMainSize - end - end - - -- Add the last line if it has children - if #currentLine > 0 then - table.insert(lines, currentLine) - end - - return lines -end - ---- Calculate auto width based on children ----@return number -function LayoutEngine:calculateAutoWidth() - if self.element == nil then - return 0 - end - - -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box - local contentWidth = self.element:calculateTextWidth() - if not self.element.children or #self.element.children == 0 then - return contentWidth - end - - -- Get flex children (children that participate in flex layout) - -- Exclude display=false (CSS display:none) and explicitly absolute children - local flexChildren = {} - for _, child in ipairs(self.element.children) do - if not child._explicitlyAbsolute and child.display ~= false then - table.insert(flexChildren, child) - end - end - - if #flexChildren == 0 then - return contentWidth - end - - local isHorizontal = self:_isHorizontal() - - if isHorizontal then - -- HORIZONTAL flex with potential wrapping - if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then - -- Container has explicit width and wrapping enabled - calculate based on wrapped lines - local availableWidth = self.element.width - local lines = self:_simulateWrap(flexChildren, availableWidth, true) - - -- Find the widest line - local maxLineWidth = contentWidth - for _, line in ipairs(lines) do - local lineWidth = 0 - for i, child in ipairs(line) do - local childBorderBoxWidth = child:getBorderBoxWidth() - local childMarginH = 0 - if child.margin then - childMarginH = child.margin.left + child.margin.right - end - lineWidth = lineWidth + childBorderBoxWidth + childMarginH - if i < #line then - lineWidth = lineWidth + self.gap - end - end - maxLineWidth = math.max(maxLineWidth, lineWidth) - end - return maxLineWidth - else - -- No wrapping or no explicit width - sum all children on one line - local totalWidth = contentWidth - for i, child in ipairs(flexChildren) do - local childBorderBoxWidth = child:getBorderBoxWidth() - local childMarginH = 0 - if child.margin then - childMarginH = child.margin.left + child.margin.right - end - totalWidth = totalWidth + childBorderBoxWidth + childMarginH - if i < #flexChildren then - totalWidth = totalWidth + self.gap - end - end - return totalWidth - end - else - -- VERTICAL flex - return max child width (including margins) - local maxWidth = contentWidth - for _, child in ipairs(flexChildren) do - local childBorderBoxWidth = child:getBorderBoxWidth() - childBorderBoxWidth = self.element:_adjustAutoWidthChildBorderBoxForManagedSelect(child, childBorderBoxWidth) - local childMarginH = 0 - if child.margin then - childMarginH = child.margin.left + child.margin.right - end - maxWidth = math.max(maxWidth, childBorderBoxWidth + childMarginH) - end - return maxWidth - end -end - ----@return number -function LayoutEngine:calculateAutoHeight() - if self.element == nil then - return 0 - end - - local height = self.element:calculateTextHeight() - if not self.element.children or #self.element.children == 0 then - return height - end - - -- Get flex children (children that participate in flex layout) - -- Exclude display=false (CSS display:none) and explicitly absolute children - local flexChildren = {} - for _, child in ipairs(self.element.children) do - if not child._explicitlyAbsolute and child.display ~= false then - table.insert(flexChildren, child) - end - end - - if #flexChildren == 0 then - return height - end - - local isVertical = not self:_isHorizontal() - - if isVertical then - -- VERTICAL flex with potential wrapping - if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.height and self.element.height > 0 then - -- Container has explicit height and wrapping enabled - calculate based on wrapped lines - local availableHeight = self.element.height - local lines = self:_simulateWrap(flexChildren, availableHeight, false) - - -- Sum all line heights - local totalLinesHeight = height - for i, line in ipairs(lines) do - local lineHeight = 0 - for _, child in ipairs(line) do - local childBorderBoxHeight = child:getBorderBoxHeight() - local childMarginV = 0 - if child.margin then - childMarginV = child.margin.top + child.margin.bottom - end - lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV) - end - totalLinesHeight = totalLinesHeight + lineHeight - if i < #lines then - totalLinesHeight = totalLinesHeight + self.gap - end - end - return totalLinesHeight - else - -- No wrapping or no explicit height - sum all children on one line - local totalHeight = height - for i, child in ipairs(flexChildren) do - local childBorderBoxHeight = child:getBorderBoxHeight() - local childMarginV = 0 - if child.margin then - childMarginV = child.margin.top + child.margin.bottom - end - totalHeight = totalHeight + childBorderBoxHeight + childMarginV - if i < #flexChildren then - totalHeight = totalHeight + self.gap - end - end - return totalHeight - end - else - -- HORIZONTAL flex with potential wrapping - if self.flexWrap ~= self._FlexWrap.NOWRAP and self.element.width and self.element.width > 0 then - -- Container has explicit width and wrapping enabled - calculate based on wrapped lines - local availableWidth = self.element.width - local lines = self:_simulateWrap(flexChildren, availableWidth, true) - - -- Sum all line heights (cross-axis for horizontal flex) - local totalLinesHeight = height - for i, line in ipairs(lines) do - local lineHeight = 0 - for _, child in ipairs(line) do - local childBorderBoxHeight = child:getBorderBoxHeight() - local childMarginV = 0 - if child.margin then - childMarginV = child.margin.top + child.margin.bottom - end - lineHeight = math.max(lineHeight, childBorderBoxHeight + childMarginV) - end - totalLinesHeight = totalLinesHeight + lineHeight - if i < #lines then - totalLinesHeight = totalLinesHeight + self.gap - end - end - return totalLinesHeight - else - -- No wrapping or no explicit width - return max child height (including margins) - local maxHeight = height - for _, child in ipairs(flexChildren) do - local childBorderBoxHeight = child:getBorderBoxHeight() - local childMarginV = 0 - if child.margin then - childMarginV = child.margin.top + child.margin.bottom - end - maxHeight = math.max(maxHeight, childBorderBoxHeight + childMarginV) - end - return maxHeight - end - end -end - ---- Recalculate units based on new viewport dimensions (for vw, vh, % units) ----@param newViewportWidth number ----@param newViewportHeight number -function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) - if self.element == nil then - return - end - local Units = self._Units - - -- Get updated scale factors - local scaleX, scaleY = self._Context.getScaleFactors() - - -- Recalculate border-box width if using viewport or percentage units (skip auto-sized) - -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved - if self.element.units.width.unit ~= "px" and self.element.units.width.unit ~= "auto" then - local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth - self.element._borderBoxWidth = Units.resolve( - self.element.units.width.value, - self.element.units.width.unit, - newViewportWidth, - newViewportHeight, - parentWidth - ) - elseif self.element.units.width.unit == "px" and self.element.units.width.value and self._Context.baseScale then - -- Reapply base scaling to pixel widths (border-box) - self.element._borderBoxWidth = self.element.units.width.value * scaleX - end - - -- Recalculate border-box height if using viewport or percentage units (skip auto-sized) - -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved - if self.element.units.height.unit ~= "px" and self.element.units.height.unit ~= "auto" then - local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight - self.element._borderBoxHeight = Units.resolve( - self.element.units.height.value, - self.element.units.height.unit, - newViewportWidth, - newViewportHeight, - parentHeight - ) - elseif self.element.units.height.unit == "px" and self.element.units.height.value and self._Context.baseScale then - -- Reapply base scaling to pixel heights (border-box) - self.element._borderBoxHeight = self.element.units.height.value * scaleY - end - - -- Recalculate position if using viewport or percentage units - -- Skip position recalculation for flex children (non-explicitly-absolute children with a parent) - -- Their x/y is entirely controlled by the parent's layoutChildren() call - local isFlexChild = self.element.parent and not self.element._explicitlyAbsolute - if not isFlexChild then - if self.element.units.x.unit ~= "px" then - local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth - local baseX = self.element.parent and self.element.parent.x or 0 - local offsetX = Units.resolve( - self.element.units.x.value, - self.element.units.x.unit, - newViewportWidth, - newViewportHeight, - parentWidth - ) - self.element.x = baseX + offsetX - else - -- For pixel units, update position relative to parent's new position (with base scaling) - if self.element.parent then - local baseX = self.element.parent.x - local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX) - or self.element.units.x.value - self.element.x = baseX + scaledOffset - elseif self._Context.baseScale then - -- Top-level element with pixel position - apply base scaling - self.element.x = self.element.units.x.value * scaleX - end - end - - if self.element.units.y.unit ~= "px" then - local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight - local baseY = self.element.parent and self.element.parent.y or 0 - local offsetY = Units.resolve( - self.element.units.y.value, - self.element.units.y.unit, - newViewportWidth, - newViewportHeight, - parentHeight - ) - self.element.y = baseY + offsetY - else - -- For pixel units, update position relative to parent's new position (with base scaling) - if self.element.parent then - local baseY = self.element.parent.y - local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY) - or self.element.units.y.value - self.element.y = baseY + scaledOffset - elseif self._Context.baseScale then - -- Top-level element with pixel position - apply base scaling - self.element.y = self.element.units.y.value * scaleY - end - end - end - - -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units - if self.element.autoScaleText and self.element.units.textSize.value then - local unit = self.element.units.textSize.unit - local value = self.element.units.textSize.value - - if unit == "px" and self._Context.baseScale then - -- With base scaling: scale pixel values relative to base resolution - self.element.textSize = value * scaleY - elseif unit == "px" then - -- Without base scaling but auto-scaling enabled: text doesn't scale - self.element.textSize = value - elseif unit == "%" or unit == "vh" then - -- Percentage and vh are relative to viewport height - self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight) - elseif unit == "vw" then - -- vw is relative to viewport width - self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth) - else - self.element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) - end - - -- Apply min/max constraints (with base scaling) - local minSize = self.element.minTextSize - and (self._Context.baseScale and (self.element.minTextSize * scaleY) or self.element.minTextSize) - local maxSize = self.element.maxTextSize - and (self._Context.baseScale and (self.element.maxTextSize * scaleY) or self.element.maxTextSize) - - if minSize and self.element.textSize < minSize then - self.element.textSize = minSize - end - if maxSize and self.element.textSize > maxSize then - self.element.textSize = maxSize - end - - -- Protect against too-small text sizes (minimum 1px) - if self.element.textSize < 1 then - self.element.textSize = 1 -- Minimum 1px - end - elseif self.element.units.textSize.unit == "px" and self.element.units.textSize.value and self._Context.baseScale then - -- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes - self.element.textSize = self.element.units.textSize.value * scaleY - - -- Protect against too-small text sizes (minimum 1px) - if self.element.textSize < 1 then - self.element.textSize = 1 -- Minimum 1px - end - end - - -- Final protection: ensure textSize is always at least 1px (catches all edge cases) - if self.element.text and self.element.textSize and self.element.textSize < 1 then - self.element.textSize = 1 -- Minimum 1px - end - - -- Recalculate gap if using viewport or percentage units - if self.element.units.gap.unit ~= "px" then - local containerSize = (self:_isHorizontal()) - and (self.element.parent and self.element.parent.width or newViewportWidth) - or (self.element.parent and self.element.parent.height or newViewportHeight) - self.element.gap = Units.resolve( - self.element.units.gap.value, - self.element.units.gap.unit, - newViewportWidth, - newViewportHeight, - containerSize - ) - end - - -- Recalculate flexBasis if using viewport or percentage units - if - self.element.units.flexBasis - and self.element.units.flexBasis.unit ~= "auto" - and self.element.units.flexBasis.unit ~= "px" - then - local value, unit = self.element.units.flexBasis.value, self.element.units.flexBasis.unit - -- flexBasis uses parent main-axis size for percentage resolution. - local parentMainIsHorizontal = true - if self.element.parent and self.element.parent.flexDirection then - local pd = self.element.parent.flexDirection - parentMainIsHorizontal = pd == self._FlexDirection.HORIZONTAL or pd == self._FlexDirection.HORIZONTAL_REVERSE - end - local parentSize = newViewportWidth - if self.element.parent then - if parentMainIsHorizontal then - parentSize = self.element.parent.width - else - parentSize = self.element.parent.height - end - end - local resolvedBasis = Units.resolve(value, unit, newViewportWidth, newViewportHeight, parentSize) - if type(resolvedBasis) == "number" then - self.element.flexBasis = resolvedBasis - end - end - - -- Recalculate spacing (padding/margin) if using viewport or percentage units - -- For percentage-based padding: - -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) - -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) - local parentBorderBoxWidth = self.element.parent and self.element.parent._borderBoxWidth - or self.element._borderBoxWidth - or newViewportWidth - local parentBorderBoxHeight = self.element.parent and self.element.parent._borderBoxHeight - or self.element._borderBoxHeight - or newViewportHeight - - -- Handle shorthand properties first (horizontal/vertical) - local resolvedHorizontalPadding = nil - local resolvedVerticalPadding = nil - - if self.element.units.padding.horizontal and self.element.units.padding.horizontal.unit ~= "px" then - resolvedHorizontalPadding = Units.resolve( - self.element.units.padding.horizontal.value, - self.element.units.padding.horizontal.unit, - newViewportWidth, - newViewportHeight, - parentBorderBoxWidth - ) - elseif self.element.units.padding.horizontal and self.element.units.padding.horizontal.value then - resolvedHorizontalPadding = self.element.units.padding.horizontal.value - end - - if self.element.units.padding.vertical and self.element.units.padding.vertical.unit ~= "px" then - resolvedVerticalPadding = Units.resolve( - self.element.units.padding.vertical.value, - self.element.units.padding.vertical.unit, - newViewportWidth, - newViewportHeight, - parentBorderBoxHeight - ) - elseif self.element.units.padding.vertical and self.element.units.padding.vertical.value then - resolvedVerticalPadding = self.element.units.padding.vertical.value - end - -- Resolve individual padding sides (with fallback to shorthand) - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - -- Check if this side was explicitly set or if we should use shorthand - local useShorthand = false - if not self.element.units.padding[side].explicit then - -- Not explicitly set, check if we have shorthand - if side == "left" or side == "right" then - useShorthand = resolvedHorizontalPadding ~= nil - elseif side == "top" or side == "bottom" then - useShorthand = resolvedVerticalPadding ~= nil - end - end - - if useShorthand then - -- Use shorthand value - if side == "left" or side == "right" then - self.element.padding[side] = resolvedHorizontalPadding - else - self.element.padding[side] = resolvedVerticalPadding - end - elseif self.element.units.padding[side].unit ~= "px" then - -- Recalculate non-pixel units - local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.element.padding[side] = Units.resolve( - self.element.units.padding[side].value, - self.element.units.padding[side].unit, - newViewportWidth, - newViewportHeight, - parentSize - ) - end - -- If unit is "px" and not using shorthand, value stays the same - end - - -- Handle margin shorthand properties - local resolvedHorizontalMargin = nil - local resolvedVerticalMargin = nil - - if self.element.units.margin.horizontal and self.element.units.margin.horizontal.unit ~= "px" then - resolvedHorizontalMargin = Units.resolve( - self.element.units.margin.horizontal.value, - self.element.units.margin.horizontal.unit, - newViewportWidth, - newViewportHeight, - parentBorderBoxWidth - ) - elseif self.element.units.margin.horizontal and self.element.units.margin.horizontal.value then - resolvedHorizontalMargin = self.element.units.margin.horizontal.value - end - - if self.element.units.margin.vertical and self.element.units.margin.vertical.unit ~= "px" then - resolvedVerticalMargin = Units.resolve( - self.element.units.margin.vertical.value, - self.element.units.margin.vertical.unit, - newViewportWidth, - newViewportHeight, - parentBorderBoxHeight - ) - elseif self.element.units.margin.vertical and self.element.units.margin.vertical.value then - resolvedVerticalMargin = self.element.units.margin.vertical.value - end - - -- Resolve individual margin sides (with fallback to shorthand) - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - -- Check if this side was explicitly set or if we should use shorthand - local useShorthand = false - if not self.element.units.margin[side].explicit then - -- Not explicitly set, check if we have shorthand - if side == "left" or side == "right" then - useShorthand = resolvedHorizontalMargin ~= nil - elseif side == "top" or side == "bottom" then - useShorthand = resolvedVerticalMargin ~= nil - end - end - - if useShorthand then - -- Use shorthand value - if side == "left" or side == "right" then - self.element.margin[side] = resolvedHorizontalMargin - else - self.element.margin[side] = resolvedVerticalMargin - end - elseif self.element.units.margin[side].unit ~= "px" then - -- Recalculate non-pixel units - local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.element.margin[side] = Units.resolve( - self.element.units.margin[side].value, - self.element.units.margin[side].unit, - newViewportWidth, - newViewportHeight, - parentSize - ) - end - -- If unit is "px" and not using shorthand, value stays the same - end - - -- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions - -- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier - -- Now we calculate content width/height by subtracting padding - -- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed) - if self.element.units.width.unit ~= "auto" and self.element.units.width.unit ~= "px" then - -- _borderBoxWidth was recalculated for viewport/percentage units - -- Calculate content width by subtracting padding - self.element.width = - math.max(0, self.element._borderBoxWidth - self.element.padding.left - self.element.padding.right) - elseif self.element.units.width.unit == "auto" then - -- For auto-sized elements, width is content width (calculated in resize method) - -- Update border-box to include padding - self.element._borderBoxWidth = self.element.width + self.element.padding.left + self.element.padding.right - end - -- For pixel units, width stays as-is (may have been manually modified) - - if self.element.units.height.unit ~= "auto" and self.element.units.height.unit ~= "px" then - -- _borderBoxHeight was recalculated for viewport/percentage units - -- Calculate content height by subtracting padding - self.element.height = - math.max(0, self.element._borderBoxHeight - self.element.padding.top - self.element.padding.bottom) - elseif self.element.units.height.unit == "auto" then - -- For auto-sized elements, height is content height (calculated in resize method) - -- Update border-box to include padding - self.element._borderBoxHeight = self.element.height + self.element.padding.top + self.element.padding.bottom - end - -- For pixel units, height stays as-is (may have been manually modified) - - -- Detect overflow after layout calculations - if self.element._detectOverflow then - self.element:_detectOverflow() - end -end - ---- Check if layout can be skipped based on cached state (memoization) ----@return boolean canSkip True if layout hasn't changed and can be skipped -function LayoutEngine:_canSkipLayout() - if not self.element then - return false - end - - -- Performance optimization: Check dirty flags first (fastest check) - -- If element or children are marked dirty, we must recalculate - if self.element._dirty or self.element._childrenDirty then - -- Clear dirty flags after acknowledging them - self.element._dirty = false - self.element._childrenDirty = false - return false - end - - -- If not dirty, check if layout inputs have actually changed (secondary check) - local childrenCount = #self.element.children - local containerWidth = self.element.width - local containerHeight = self.element.height - local containerX = self.element.x - local containerY = self.element.y - - -- Generate simple hash of children dimensions + display state - local childrenHash = "" - for i, child in ipairs(self.element.children) do - if i <= 5 then -- Only hash first 5 children for performance - childrenHash = childrenHash .. child.width .. "x" .. child.height .. "d" .. tostring(child.display) .. "," - end - end - - local cache = self._layoutCache - - -- Check if layout inputs have changed - if - cache.childrenCount == childrenCount - and cache.containerWidth == containerWidth - and cache.containerHeight == containerHeight - and cache.containerX == containerX - and cache.containerY == containerY - and cache.childrenHash == childrenHash - then - return true -- Layout hasn't changed, can skip - end - - -- Update cache with current values - cache.childrenCount = childrenCount - cache.containerWidth = containerWidth - cache.containerHeight = containerHeight - cache.containerX = containerX - cache.containerY = containerY - cache.childrenHash = childrenHash - - return false -- Layout has changed, must recalculate -end - ---- Track layout recalculations and warn about excessive layouts -function LayoutEngine:_trackLayoutRecalculation() - if not LayoutEngine._Performance or not LayoutEngine._Performance.warningsEnabled then - return - end - - -- Get current frame count from Context - local currentFrame = self._Context and self._Context._frameNumber or 0 - - -- Reset counter on new frame - if currentFrame ~= self._lastFrameCount then - self._lastFrameCount = currentFrame - self._layoutCount = 0 - end - - -- Increment layout count - self._layoutCount = self._layoutCount + 1 - - -- Warn if layout is recalculated excessively this frame - if self._layoutCount >= 10 then - local elementId = self.element and self.element.id or "unnamed" - LayoutEngine._Performance:logWarning( - string.format("excessive_layout_%s", elementId), - "LayoutEngine", - string.format("Layout recalculated %d times this frame for element '%s'", self._layoutCount, elementId), - { layoutCount = self._layoutCount, elementId = elementId }, - "This may indicate a layout thrashing issue. Check for circular dependencies or dynamic sizing that triggers re-layout" - ) - end -end - -return LayoutEngine diff --git a/libs/flexlove/modules/MemoryScanner.lua b/libs/flexlove/modules/MemoryScanner.lua deleted file mode 100644 index 07f5ca70..00000000 --- a/libs/flexlove/modules/MemoryScanner.lua +++ /dev/null @@ -1,697 +0,0 @@ ----@class MemoryScanner ----@field _StateManager table ----@field _Context table ----@field _ImageCache table ----@field _ErrorHandler table -local MemoryScanner = {} - ----Initialize MemoryScanner with dependencies ----@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table} -function MemoryScanner.init(deps) - MemoryScanner._StateManager = deps.StateManager - MemoryScanner._Context = deps.Context - MemoryScanner._ImageCache = deps.ImageCache - MemoryScanner._ErrorHandler = deps.ErrorHandler -end - ----Count items in a table ----@param tbl table ----@return number -local function countTable(tbl) - local count = 0 - for _ in pairs(tbl) do - count = count + 1 - end - return count -end - ----Calculate memory size estimate for a table (recursive) ----@param tbl table ----@param visited table? Tracking table to prevent circular references ----@param depth number? Current recursion depth ----@return number bytes Estimated memory usage in bytes -local function estimateTableSize(tbl, visited, depth) - if type(tbl) ~= "table" then - return 0 - end - - visited = visited or {} - depth = depth or 0 - - -- Limit recursion depth to prevent stack overflow - if depth > 10 then - return 0 - end - - -- Check for circular references - if visited[tbl] then - return 0 - end - visited[tbl] = true - - local size = 40 -- Base table overhead (approximate) - - for k, v in pairs(tbl) do - -- Key size - if type(k) == "string" then - size = size + #k + 24 -- String overhead - elseif type(k) == "number" then - size = size + 8 - else - size = size + 8 -- Reference - end - - -- Value size - if type(v) == "string" then - size = size + #v + 24 - elseif type(v) == "number" then - size = size + 8 - elseif type(v) == "boolean" then - size = size + 4 - elseif type(v) == "table" then - size = size + estimateTableSize(v, visited, depth + 1) - elseif type(v) == "function" then - size = size + 16 -- Function reference - else - size = size + 8 -- Other references - end - end - - return size -end - ----Scan StateManager for memory issues ----@return table report Detailed report of StateManager memory usage -function MemoryScanner.scanStateManager() - local report = { - stateCount = 0, - stateStoreSize = 0, - metadataSize = 0, - callSiteCounterSize = 0, - orphanedStates = {}, - staleStates = {}, - largeStates = {}, - issues = {}, - } - - if not MemoryScanner._StateManager then - table.insert(report.issues, { - severity = "error", - message = "StateManager not initialized", - }) - return report - end - - local internal = MemoryScanner._StateManager._getInternalState() - local stateStore = internal.stateStore - local stateMetadata = internal.stateMetadata - local callSiteCounters = internal.callSiteCounters - local currentFrame = MemoryScanner._StateManager.getFrameNumber() - - -- Count states - report.stateCount = countTable(stateStore) - - -- Estimate sizes - report.stateStoreSize = estimateTableSize(stateStore) - report.metadataSize = estimateTableSize(stateMetadata) - report.callSiteCounterSize = estimateTableSize(callSiteCounters) - - -- Check for orphaned states (metadata without state) - for id, _ in pairs(stateMetadata) do - if not stateStore[id] then - table.insert(report.orphanedStates, id) - end - end - - -- Check for stale states (not accessed in many frames) - local staleThreshold = 120 -- 2 seconds at 60fps - for id, meta in pairs(stateMetadata) do - local framesSinceAccess = currentFrame - meta.lastFrame - if framesSinceAccess > staleThreshold then - table.insert(report.staleStates, { - id = id, - framesSinceAccess = framesSinceAccess, - createdFrame = meta.createdFrame, - accessCount = meta.accessCount, - }) - end - end - - -- Check for large states (may indicate memory bloat) - for id, state in pairs(stateStore) do - local stateSize = estimateTableSize(state) - if stateSize > 1024 then -- More than 1KB - table.insert(report.largeStates, { - id = id, - size = stateSize, - keyCount = countTable(state), - }) - end - end - - -- Check callSiteCounters (should be near 0 after frame cleanup) - local callSiteCount = countTable(callSiteCounters) - if callSiteCount > 100 then - table.insert(report.issues, { - severity = "warning", - message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount), - suggestion = "incrementFrame() may not be called properly, or counters aren't being reset", - }) - end - - -- Check for excessive state count - if report.stateCount > 500 then - table.insert(report.issues, { - severity = "warning", - message = string.format("High state count: %d states", report.stateCount), - suggestion = "Consider reducing element count or implementing more aggressive cleanup", - }) - end - - -- Check for orphaned states - if #report.orphanedStates > 0 then - table.insert(report.issues, { - severity = "error", - message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates), - suggestion = "This indicates a bug in state management - metadata should be cleaned up with state", - }) - end - - -- Check for stale states - if #report.staleStates > 10 then - table.insert(report.issues, { - severity = "warning", - message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates), - suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames", - }) - end - - return report -end - ----Scan Context for memory issues ----@return table report Detailed report of Context memory usage -function MemoryScanner.scanContext() - local report = { - topElementCount = 0, - zIndexElementCount = 0, - frameElementCount = 0, - issues = {}, - } - - if not MemoryScanner._Context then - table.insert(report.issues, { - severity = "error", - message = "Context not initialized", - }) - return report - end - - -- Count elements - report.topElementCount = #MemoryScanner._Context.topElements - report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements - report.frameElementCount = #MemoryScanner._Context._currentFrameElements - - -- Check for stale z-index elements (should be cleared each frame) - if MemoryScanner._Context.isImmediateMode() then - -- In immediate mode, _zIndexOrderedElements should be cleared at frame start - -- If it has elements outside of frame rendering, that's a leak - if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then - table.insert(report.issues, { - severity = "warning", - message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount), - suggestion = "clearFrameElements() may not be called properly in beginFrame()", - }) - end - end - - -- Check for excessive element count - if report.topElementCount > 100 then - table.insert(report.issues, { - severity = "info", - message = string.format("High top-level element count: %d", report.topElementCount), - suggestion = "Consider consolidating elements or using fewer top-level containers", - }) - end - - return report -end - ----Scan ImageCache for memory issues ----@return table report Detailed report of ImageCache memory usage -function MemoryScanner.scanImageCache() - local report = { - imageCount = 0, - estimatedMemory = 0, - issues = {}, - } - - if not MemoryScanner._ImageCache then - table.insert(report.issues, { - severity = "error", - message = "ImageCache not initialized", - }) - return report - end - - local stats = MemoryScanner._ImageCache.getStats() - report.imageCount = stats.count - report.estimatedMemory = stats.memoryEstimate - - -- Check for excessive memory usage (>100MB) - if report.estimatedMemory > 100 * 1024 * 1024 then - table.insert(report.issues, { - severity = "warning", - message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024), - suggestion = "Consider implementing cache eviction or clearing unused images", - }) - end - - -- Check for excessive image count - if report.imageCount > 50 then - table.insert(report.issues, { - severity = "info", - message = string.format("ImageCache has %d images", report.imageCount), - suggestion = "Review if all cached images are necessary", - }) - end - - return report -end - ----Check if a circular reference is intentional (parent-child, module, or metatable) ----@param path string The current path where circular ref was detected ----@param originalPath string The original path where the table was first seen ----@return boolean True if this is an intentional circular reference -local function isIntentionalCircularReference(path, originalPath) - -- Pattern 1: child.parent points back to parent - -- Example: "topElements.1.children.1.parent" -> "topElements.1" - if path:match("%.parent$") then - local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$") - if parentPath == originalPath then - return true - end - end - - -- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree - -- Example: "topElements.1" -> "topElements.1.children.1.parent" - if originalPath:match("%.parent$") then - local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$") - if childParentPath == path then - return true - end - end - - -- Pattern 3: Check for nested parent-child cycles - -- child.children[n].parent -> child - local segments = {} - for segment in path:gmatch("[^.]+") do - table.insert(segments, segment) - end - - -- Look for .children.N.parent pattern - for i = 1, #segments - 2 do - if segments[i] == "children" and segments[i + 2] == "parent" then - -- Reconstruct path without the .children.N.parent suffix - local reconstructedPath = table.concat(segments, ".", 1, i - 1) - if reconstructedPath == originalPath then - return true - end - end - end - - -- Pattern 4: Metatable __index self-references (modules) - -- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme" - if path:match("%.__index$") then - local basePath = path:match("^(.+)%.__index$") - if basePath == originalPath then - return true - end - end - - -- Pattern 5: Shared module references (elements sharing same module instances) - -- Example: Multiple elements referencing _utils, _Theme, _Blur, etc. - -- These start with _ and are typically modules - local pathModuleName = path:match("%.(_[%w]+)%.") - local originalModuleName = originalPath:match("%.(_[%w]+)%.") - - if pathModuleName and originalModuleName then - -- If both paths reference the same internal module (starting with _), it's intentional - if pathModuleName == originalModuleName then - return true - end - end - - -- Pattern 6: Shared Color/Transform objects between elements - -- These are value objects that can be safely shared - if path:match("Color") and originalPath:match("Color") then - return true - end - if path:match("Transform") and originalPath:match("Transform") then - return true - end - - -- Pattern 7: LayoutEngine holding reference to its element - -- Example: "element._layoutEngine.element" -> "element" - if path:match("%._layoutEngine%.element$") then - local elementPath = path:match("^(.+)%._layoutEngine%.element$") - if elementPath == originalPath then - return true - end - end - - -- Pattern 8: Renderer holding references to element properties - -- Example: "element._renderer.cornerRadius" -> "element.cornerRadius" - if path:match("%._renderer%.") then - local rendererBasePath = path:match("^(.+)%._renderer%.") - local originalBasePath = originalPath:match("^(.+)%.") - if rendererBasePath == originalBasePath then - return true - end - end - - -- Pattern 9: Context reference from layout engine (shared singleton) - -- Example: "element._layoutEngine._Context.topElements" -> "topElements" - if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then - return true - end - - return false -end - ----Detect circular references in a table ----@param tbl table Table to check ----@param path string? Current path (for reporting) ----@param visited table? Tracking table ----@return table[] circularRefs Array of circular reference paths ----@return table[] intentionalRefs Array of intentional parent-child refs -local function detectCircularReferences(tbl, path, visited) - if type(tbl) ~= "table" then - return {}, {} - end - - path = path or "root" - visited = visited or {} - local circularRefs = {} - local intentionalRefs = {} - - -- Check if we've seen this table before - if visited[tbl] then - local ref = { - path = path, - originalPath = visited[tbl], - } - - -- Determine if this is an intentional circular reference - if isIntentionalCircularReference(path, visited[tbl]) then - table.insert(intentionalRefs, ref) - else - table.insert(circularRefs, ref) - end - - return circularRefs, intentionalRefs - end - - -- Mark as visited - visited[tbl] = path - - -- Recursively check children - for k, v in pairs(tbl) do - if type(v) == "table" then - local childPath = path .. "." .. tostring(k) - local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited) - for _, ref in ipairs(childRefs) do - table.insert(circularRefs, ref) - end - for _, ref in ipairs(childIntentionalRefs) do - table.insert(intentionalRefs, ref) - end - end - end - - return circularRefs, intentionalRefs -end - ----Scan for circular references in immediate mode ----@return table report Detailed report of circular references -function MemoryScanner.scanCircularReferences() - local report = { - stateStoreCircularRefs = {}, - stateStoreIntentionalRefs = {}, - contextCircularRefs = {}, - contextIntentionalRefs = {}, - issues = {}, - } - - if MemoryScanner._StateManager then - local internal = MemoryScanner._StateManager._getInternalState() - report.stateStoreCircularRefs, report.stateStoreIntentionalRefs = - detectCircularReferences(internal.stateStore, "stateStore") - end - - if MemoryScanner._Context then - report.contextCircularRefs, report.contextIntentionalRefs = - detectCircularReferences(MemoryScanner._Context.topElements, "topElements") - end - - -- Report issues only for cross-module circular references - if #report.stateStoreCircularRefs > 0 then - table.insert(report.issues, { - severity = "info", - message = string.format( - "Found %d cross-module circular references in StateManager", - #report.stateStoreCircularRefs - ), - suggestion = "These are typically architectural dependencies between modules, not memory leaks", - }) - end - - if #report.contextCircularRefs > 0 then - table.insert(report.issues, { - severity = "info", - message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs), - suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks", - }) - end - - return report -end - ----Run comprehensive memory scan ----@return table report Complete memory analysis report -function MemoryScanner.scan() - local startMemory = collectgarbage("count") - - local report = { - timestamp = os.time(), - startMemory = startMemory / 1024, -- MB - stateManager = MemoryScanner.scanStateManager(), - context = MemoryScanner.scanContext(), - imageCache = MemoryScanner.scanImageCache(), - circularRefs = MemoryScanner.scanCircularReferences(), - summary = { - totalIssues = 0, - criticalIssues = 0, - warnings = 0, - info = 0, - }, - } - - -- Count issues by severity - local function countIssues(subReport) - for _, issue in ipairs(subReport.issues or {}) do - report.summary.totalIssues = report.summary.totalIssues + 1 - if issue.severity == "error" then - report.summary.criticalIssues = report.summary.criticalIssues + 1 - elseif issue.severity == "warning" then - report.summary.warnings = report.summary.warnings + 1 - elseif issue.severity == "info" then - report.summary.info = report.summary.info + 1 - end - end - end - - countIssues(report.stateManager) - countIssues(report.context) - countIssues(report.imageCache) - countIssues(report.circularRefs) - - -- Force GC and measure freed memory - local beforeGC = collectgarbage("count") - collectgarbage("collect") - collectgarbage("collect") - local afterGC = collectgarbage("count") - - report.gcAnalysis = { - beforeGC = beforeGC / 1024, -- MB - afterGC = afterGC / 1024, -- MB - freed = (beforeGC - afterGC) / 1024, -- MB - freedPercent = ((beforeGC - afterGC) / beforeGC) * 100, - } - - -- Analyze GC effectiveness - if report.gcAnalysis.freedPercent < 5 then - table.insert(report.stateManager.issues, { - severity = "info", - message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent), - suggestion = "Most memory is still referenced - this is normal if UI is active", - }) - elseif report.gcAnalysis.freedPercent > 30 then - table.insert(report.stateManager.issues, { - severity = "warning", - message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent), - suggestion = "Significant memory was unreferenced - may indicate cleanup issues", - }) - end - - return report -end - ----Format report as human-readable string ----@param report table Memory scan report ----@return string formatted Formatted report -function MemoryScanner.formatReport(report) - local lines = {} - - table.insert(lines, "=== FlexLöve Memory Scanner Report ===") - table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp))) - table.insert(lines, string.format("Memory: %.2f MB", report.startMemory)) - table.insert(lines, "") - - -- Summary - table.insert(lines, "--- Summary ---") - table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues)) - table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues)) - table.insert(lines, string.format(" Warnings: %d", report.summary.warnings)) - table.insert(lines, string.format(" Info: %d", report.summary.info)) - table.insert(lines, "") - - -- StateManager - table.insert(lines, "--- StateManager ---") - table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount)) - table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024)) - table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024)) - table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024)) - table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates)) - table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates)) - table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates)) - - if #report.stateManager.issues > 0 then - table.insert(lines, "Issues:") - for _, issue in ipairs(report.stateManager.issues) do - table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) - if issue.suggestion then - table.insert(lines, string.format(" → %s", issue.suggestion)) - end - end - end - table.insert(lines, "") - - -- Context - table.insert(lines, "--- Context ---") - table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount)) - table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount)) - table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount)) - - if #report.context.issues > 0 then - table.insert(lines, "Issues:") - for _, issue in ipairs(report.context.issues) do - table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) - if issue.suggestion then - table.insert(lines, string.format(" → %s", issue.suggestion)) - end - end - end - table.insert(lines, "") - - -- ImageCache - table.insert(lines, "--- ImageCache ---") - table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount)) - table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024)) - - if #report.imageCache.issues > 0 then - table.insert(lines, "Issues:") - for _, issue in ipairs(report.imageCache.issues) do - table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) - if issue.suggestion then - table.insert(lines, string.format(" → %s", issue.suggestion)) - end - end - end - table.insert(lines, "") - - -- Circular References - table.insert(lines, "--- Circular References ---") - table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs)) - table.insert( - lines, - string.format( - "StateStore (Intentional - parent-child, modules, metatables): %d", - #report.circularRefs.stateStoreIntentionalRefs - ) - ) - table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs)) - table.insert( - lines, - string.format( - "Context (Intentional - parent-child, modules, metatables): %d", - #report.circularRefs.contextIntentionalRefs - ) - ) - - if #report.circularRefs.issues > 0 then - table.insert(lines, "Issues:") - for _, issue in ipairs(report.circularRefs.issues) do - table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) - if issue.suggestion then - table.insert(lines, string.format(" → %s", issue.suggestion)) - end - end - else - table.insert(lines, " ✓ No unexpected circular references detected") - end - table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks") - table.insert(lines, "") - - -- GC Analysis - table.insert(lines, "--- Garbage Collection Analysis ---") - table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC)) - table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC)) - table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent)) - table.insert(lines, "") - - table.insert(lines, "=== End Report ===") - - return table.concat(lines, "\n") -end - ----Save report to file ----@param report table Memory scan report ----@param filename string? Output filename (default: memory_report.txt) -function MemoryScanner.saveReport(report, filename) - filename = filename or "memory_report.txt" - local formatted = MemoryScanner.formatReport(report) - - local file = io.open(filename, "w") - if file then - file:write(formatted) - file:close() - if MemoryScanner._ErrorHandler then - MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { - resourceType = "report", - path = filename, - status = "saved", - }) - end - else - if MemoryScanner._ErrorHandler then - MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { - resourceType = "report", - path = filename, - status = "failed to save", - }) - end - end -end - -return MemoryScanner diff --git a/libs/flexlove/modules/ModuleLoader.lua b/libs/flexlove/modules/ModuleLoader.lua deleted file mode 100644 index 1b48daf0..00000000 --- a/libs/flexlove/modules/ModuleLoader.lua +++ /dev/null @@ -1,202 +0,0 @@ ----@class ModuleLoader -local ModuleLoader = {} - --- Module registry to track loaded vs. stub modules -ModuleLoader._registry = {} -ModuleLoader._ErrorHandler = nil - ---- Initialize ModuleLoader with dependencies ----@param deps table -function ModuleLoader.init(deps) - ModuleLoader._ErrorHandler = deps.ErrorHandler -end - ---- Create a null-object stub for a missing optional module ---- Provides safe defaults that won't cause runtime errors ----@param moduleName string ----@return table -local function createNullObject(moduleName) - local stub = { - _isStub = true, - _moduleName = moduleName, - } - - -- Common method stubs that return safe defaults - local metatable = { - __index = function(_, key) - -- Common initialization method - if key == "init" then - return function() - return stub - end - end - - -- Common constructor method - if key == "new" then - return function() - return stub - end - end - - -- Common draw method - if key == "draw" then - return function() end - end - - -- Common update method - if key == "update" then - return function() end - end - - -- Common render method - if key == "render" then - return function() end - end - - -- Common cleanup method - if key == "destroy" then - return function() end - end - - -- Common cleanup method - if key == "cleanup" then - return function() end - end - - -- Common clear method - if key == "clear" then - return function() end - end - - -- Common reset method - if key == "reset" then - return function() end - end - - -- Common get method - if key == "get" then - return function() - return nil - end - end - - -- Common set method - if key == "set" then - return function() end - end - - -- Common load method - if key == "load" then - return function() - return stub - end - end - - -- Common cache-related methods - if key == "cache" or key == "getCache" or key == "clearCache" then - return function() - return {} - end - end - - -- For any unknown method, return a no-op function that accepts any arguments - -- This allows safe method calls on stub objects (e.g., Performance:startFrame()) - return function() - return stub - end - end, - - -- Make function calls safe (in case the stub itself is called) - __call = function() - return stub - end, - } - - setmetatable(stub, metatable) - return stub -end - ---- Safely require a module with graceful fallback for optional modules ---- Returns the module if it exists, or a null-object stub if it's optional and missing ---- Throws an error if a required module is missing ----@param modulePath string Full path to the module (e.g., "modules.Performance") ----@param isOptional boolean If true, returns null-object on failure; if false, throws error ----@return table module The loaded module or a null-object stub -function ModuleLoader.safeRequire(modulePath, isOptional) - -- Check if already loaded - if ModuleLoader._registry[modulePath] then - return ModuleLoader._registry[modulePath] - end - - -- Attempt to load the module - local success, result = pcall(require, modulePath) - - if success then - -- Module loaded successfully - ModuleLoader._registry[modulePath] = result - return result - else - -- Module failed to load - if isOptional then - -- Create null-object stub for optional module - local stub = createNullObject(modulePath) - ModuleLoader._registry[modulePath] = stub - - -- Log warning about missing optional module - if ModuleLoader._ErrorHandler then - ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", { - modulePath = modulePath, - }) - end - - return stub - else - -- Required module is missing - throw error - error(string.format("Required module '%s' not found: %s", modulePath, tostring(result))) - end - end -end - ---- Check if a module is actually loaded (not a stub) ----@param modulePath string Full path to the module ----@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded -function ModuleLoader.isModuleLoaded(modulePath) - local module = ModuleLoader._registry[modulePath] - if not module then - return false - end - - -- Check if it's a stub - return not module._isStub -end - ---- Get list of all loaded modules ----@return table modules List of module paths that are actually loaded (not stubs) -function ModuleLoader.getLoadedModules() - local loaded = {} - for path, module in pairs(ModuleLoader._registry) do - if not module._isStub then - table.insert(loaded, path) - end - end - return loaded -end - ---- Get list of all stub modules ----@return table stubs List of module paths that are stubs -function ModuleLoader.getStubModules() - local stubs = {} - for path, module in pairs(ModuleLoader._registry) do - if module._isStub then - table.insert(stubs, path) - end - end - return stubs -end - ---- Clear the module registry (useful for testing) -function ModuleLoader._clearRegistry() - ModuleLoader._registry = {} -end - -return ModuleLoader diff --git a/libs/flexlove/modules/NinePatch.lua b/libs/flexlove/modules/NinePatch.lua deleted file mode 100644 index 4adb9696..00000000 --- a/libs/flexlove/modules/NinePatch.lua +++ /dev/null @@ -1,217 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local ImageScaler = require(modulePath .. "ImageScaler") - -local NinePatch = {} - --- ErrorHandler will be injected via init -local ErrorHandler = nil - ---- Initialize NinePatch with dependencies ----@param deps table Dependencies table with ErrorHandler -function NinePatch.init(deps) - if deps and deps.ErrorHandler then - ErrorHandler = deps.ErrorHandler - end - -- Also initialize ImageScaler since it's a dependency - if ImageScaler.init then - ImageScaler.init(deps) - end -end - ---- Draw a 9-patch component using Android-style rendering ---- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only ----@param component ThemeComponent ----@param atlas love.Image ----@param x number -- X position (top-left corner) ----@param y number -- Y position (top-left corner) ----@param width number -- Total width (border-box) ----@param height number -- Total height (border-box) ----@param opacity number? ----@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier) ----@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm -function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) - if not component or not atlas then - return - end - - opacity = opacity or 1 - love.graphics.setColor(1, 1, 1, opacity) - - local regions = component.regions - - -- Extract border dimensions from regions (in pixels) - local left = regions.topLeft.w - local right = regions.topRight.w - local top = regions.topLeft.h - local bottom = regions.bottomLeft.h - local centerW = regions.middleCenter.w - local centerH = regions.middleCenter.h - - -- Calculate content area (space remaining after borders) - local contentWidth = width - left - right - local contentHeight = height - top - bottom - - -- Clamp to prevent negative dimensions - contentWidth = math.max(0, contentWidth) - contentHeight = math.max(0, contentHeight) - - -- Calculate stretch scales for edges and center - local scaleX = contentWidth / centerW - local scaleY = contentHeight / centerH - - -- Create quads for each region - local atlasWidth, atlasHeight = atlas:getDimensions() - - local function makeQuad(region) - return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) - end - - -- Get corner scale multiplier - -- Priority: element-level override > component setting > default (nil = no scaling) - local scaleCorners = elementScaleCorners - if scaleCorners == nil then - scaleCorners = component.scaleCorners - end - - -- Priority: element-level override > component setting > default ("bilinear") - local scalingAlgorithm = elementScalingAlgorithm - if scalingAlgorithm == nil then - scalingAlgorithm = component.scalingAlgorithm or "bilinear" - end - - if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then - -- Initialize cache if needed - if not component._scaledRegionCache then - component._scaledRegionCache = {} - end - - -- Use the numeric scale multiplier directly - local scaleFactor = scaleCorners - - -- Helper to get or create scaled region - local function getScaledRegion(regionName, region, targetWidth, targetHeight) - local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm) - - if component._scaledRegionCache[cacheKey] then - return component._scaledRegionCache[cacheKey] - end - - -- Get ImageData from component (stored during theme loading) - local atlasData = component._loadedAtlasData - if not atlasData then - ErrorHandler.error( - "NinePatch", - "REN_007", - "No ImageData available for atlas. Image must be loaded with safeLoadImage.", - { - componentType = component.type, - } - ) - end - - local scaledData - - if scalingAlgorithm == "nearest" then - scaledData = - ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) - else - scaledData = - ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) - end - - -- Convert to image and cache - local scaledImage = love.graphics.newImage(scaledData) - component._scaledRegionCache[cacheKey] = scaledImage - - return scaledImage - end - - -- Calculate scaled dimensions for corners - local scaledLeft = math.floor(left * scaleFactor + 0.5) - local scaledRight = math.floor(right * scaleFactor + 0.5) - local scaledTop = math.floor(top * scaleFactor + 0.5) - local scaledBottom = math.floor(bottom * scaleFactor + 0.5) - - -- CORNERS (scaled using algorithm) - local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop) - local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop) - local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom) - local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom) - - love.graphics.draw(topLeftScaled, x, y) - love.graphics.draw(topRightScaled, x + width - scaledRight, y) - love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom) - love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom) - - -- Update content dimensions to account for scaled borders - local adjustedContentWidth = width - scaledLeft - scaledRight - local adjustedContentHeight = height - scaledTop - scaledBottom - adjustedContentWidth = math.max(0, adjustedContentWidth) - adjustedContentHeight = math.max(0, adjustedContentHeight) - - -- Recalculate stretch scales - local adjustedScaleX = adjustedContentWidth / centerW - local adjustedScaleY = adjustedContentHeight / centerH - - -- TOP/BOTTOM EDGES (stretch horizontally, scale vertically) - if adjustedContentWidth > 0 then - local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop) - local bottomCenterScaled = - getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom) - - love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1) - love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1) - end - - -- LEFT/RIGHT EDGES (stretch vertically, scale horizontally) - if adjustedContentHeight > 0 then - local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h) - local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h) - - love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY) - love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY) - end - - -- CENTER (stretch both dimensions, no scaling) - if adjustedContentWidth > 0 and adjustedContentHeight > 0 then - love.graphics.draw( - atlas, - makeQuad(regions.middleCenter), - x + scaledLeft, - y + scaledTop, - 0, - adjustedScaleX, - adjustedScaleY - ) - end - else - -- Original rendering logic (no scaling) - -- CORNERS (no scaling - 1:1 pixel perfect) - love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y) - love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y) - love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight) - love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight) - - -- TOP/BOTTOM EDGES (stretch horizontally only) - if contentWidth > 0 then - love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1) - love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1) - end - - -- LEFT/RIGHT EDGES (stretch vertically only) - if contentHeight > 0 then - love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY) - love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY) - end - - -- CENTER (stretch both dimensions) - if contentWidth > 0 and contentHeight > 0 then - love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY) - end - end - - -- Reset color - love.graphics.setColor(1, 1, 1, 1) -end - -return NinePatch diff --git a/libs/flexlove/modules/NumberValidation.lua b/libs/flexlove/modules/NumberValidation.lua deleted file mode 100644 index 94124e35..00000000 --- a/libs/flexlove/modules/NumberValidation.lua +++ /dev/null @@ -1,351 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- All numeric, range, type, and enum validation lives here. --- `clamp` is injected via init() to avoid a cross-import into utils. --- `ErrorHandler` is injected via init() so error reporting routes through --- the shared handler (matching the pre-split behavior of utils.validate*). - -local ErrorHandler = nil -local clamp = nil - ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = table, clamp = function } -local function init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler or ErrorHandler - clamp = deps.clamp or clamp - end -end - --- Numeric validation utilities - ---- Check if a value is NaN (not-a-number) ---- @param value any Value to check ---- @return boolean -local function isNaN(value) - return type(value) == "number" and value ~= value -end - ---- Check if a value is Infinity ---- @param value any Value to check ---- @return boolean -local function isInfinity(value) - return type(value) == "number" and (value == math.huge or value == -math.huge) -end - ---- Validate a numeric value with comprehensive checks ---- @param value any Value to validate ---- @param options table? Validation options ---- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue -local function validateNumber(value, options) - options = options or {} - - -- Check if value is a number type - if type(value) ~= "number" then - if options.default ~= nil then - return true, nil, options.default - end - return false, string.format("Value must be a number, got %s", type(value)), nil - end - - -- Check for NaN - if isNaN(value) then - if not options.allowNaN then - if options.default ~= nil then - return true, nil, options.default - end - return false, "Value is NaN (not-a-number)", nil - end - end - - -- Check for Infinity - if isInfinity(value) then - if not options.allowInfinity then - if options.default ~= nil then - return true, nil, options.default - end - return false, "Value is Infinity", nil - end - end - - -- Check for integer requirement - if options.integer and math.floor(value) ~= value then - return false, string.format("Value must be an integer, got %s", value), nil - end - - -- Check for positive requirement - if options.positive and value <= 0 then - return false, string.format("Value must be positive, got %s", value), nil - end - - -- Check bounds - if options.min and value < options.min then - return false, string.format("Value %s is below minimum %s", value, options.min), nil - end - - if options.max and value > options.max then - return false, string.format("Value %s is above maximum %s", value, options.max), nil - end - - return true, nil, value -end - ---- Sanitize a numeric value (never errors, always returns valid number) ---- @param value any Value to sanitize ---- @param min number? Minimum value ---- @param max number? Maximum value ---- @param default number? Default value for invalid inputs ---- @return number Sanitized value -local function sanitizeNumber(value, min, max, default) - default = default or 0 - min = min or -math.huge - max = max or math.huge - - -- Convert to number if possible - if type(value) == "string" then - value = tonumber(value) - end - - -- Handle non-numeric - if type(value) ~= "number" then - return default - end - - -- Handle NaN - if isNaN(value) then - return default - end - - -- Handle Infinity - if value == math.huge then - return max - end - if value == -math.huge then - return min - end - - -- Clamp to range - return clamp(value, min, max) -end - ---- Validate and convert to integer ---- @param value any Value to validate ---- @param min number? Minimum value ---- @param max number? Maximum value ---- @return boolean, string?, number? Returns valid, errorMessage, integerValue -local function validateInteger(value, min, max) - local valid, err, sanitized = validateNumber(value, { - min = min, - max = max, - integer = true, - }) - - if not valid then - return false, err, nil - end - - return true, nil, math.floor(sanitized or value) -end - ---- Validate and normalize percentage value ---- @param value any Value to validate (can be "50%", 0.5, or 50) ---- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1) -local function validatePercentage(value) - -- Handle string percentage - if type(value) == "string" then - local num = value:match("^(%d+%.?%d*)%%$") - if num then - value = tonumber(num) - if value then - value = value / 100 - end - else - value = tonumber(value) - end - end - - if type(value) ~= "number" then - return false, "Percentage must be a number", nil - end - - if isNaN(value) or isInfinity(value) then - return false, "Percentage cannot be NaN or Infinity", nil - end - - -- If value is > 1, assume it's 0-100 range - if value > 1 then - value = value / 100 - end - - -- Clamp to 0-1 - value = clamp(value, 0, 1) - - return true, nil, value -end - ---- Validate opacity value (0-1) ---- @param value any Value to validate ---- @return boolean, string?, number? Returns valid, errorMessage, opacityValue -local function validateOpacity(value) - return validateNumber(value, { min = 0, max = 1, default = 1 }) -end - ---- Validate degree value (0-360) ---- @param value any Value to validate ---- @return boolean, string?, number? Returns valid, errorMessage, degreeValue -local function validateDegrees(value) - local valid, err, sanitized = validateNumber(value) - if not valid then - return false, err, nil - end - - -- Normalize to 0-360 range - local degrees = sanitized or value - degrees = degrees % 360 - if degrees < 0 then - degrees = degrees + 360 - end - - return true, nil, degrees -end - ---- Validate coordinate value (pixel position) ---- @param value any Value to validate ---- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue -local function validateCoordinate(value) - return validateNumber(value, { - allowNaN = false, - allowInfinity = false, - }) -end - ---- Validate dimension value (width/height, must be non-negative) ---- @param value any Value to validate ---- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue -local function validateDimension(value) - return validateNumber(value, { - min = 0, - allowNaN = false, - allowInfinity = false, - }) -end - ---- Validate that a value is in an enum table ----@param value any Value to validate ----@param enumTable table Enum table with valid values ----@param propName string Property name for error messages ----@param moduleName string? Module name for error messages (default: "Element") ----@return boolean True if valid -local function validateEnum(value, enumTable, propName, moduleName) - if value == nil then - return true - end - - for _, validValue in pairs(enumTable) do - if value == validValue then - return true - end - end - - -- Build list of valid options - local validOptions = {} - for _, v in pairs(enumTable) do - table.insert(validOptions, "'" .. v .. "'") - end - table.sort(validOptions) - - if ErrorHandler then - ErrorHandler:error(moduleName or "Element", "VAL_007", { - property = propName, - expected = table.concat(validOptions, ", "), - got = tostring(value), - }) - else - error( - string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value)) - ) - end -end - ---- Validate that a numeric value is within a range ----@param value any Value to validate ----@param min number Minimum allowed value ----@param max number Maximum allowed value ----@param propName string Property name for error messages ----@param moduleName string? Module name for error messages (default: "Element") ----@return boolean True if valid -local function validateRange(value, min, max, propName, moduleName) - if value == nil then - return true - end - if type(value) ~= "number" then - if ErrorHandler then - ErrorHandler:error(moduleName or "Element", "VAL_001", { - property = propName, - expected = "number", - got = type(value), - }) - else - error(string.format("%s must be a number, got %s", propName, type(value))) - end - elseif value < min or value > max then - if ErrorHandler then - ErrorHandler:error(moduleName or "Element", "VAL_002", { - property = propName, - min = tostring(min), - max = tostring(max), - value = tostring(value), - }) - else - error( - string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value)) - ) - end - end - return true -end - ---- Validate that a value is of the expected type ----@param value any Value to validate ----@param expectedType string Expected type name ----@param propName string Property name for error messages ----@param moduleName string? Module name for error messages (default: "Element") ----@return boolean True if valid -local function validateType(value, expectedType, propName, moduleName) - if value == nil then - return true - end - local actualType = type(value) - if actualType ~= expectedType then - if ErrorHandler then - ErrorHandler:error(moduleName or "Element", "VAL_001", { - property = propName, - expected = expectedType, - got = actualType, - }) - else - error(string.format("%s must be %s, got %s", propName, expectedType, actualType)) - end - end - return true -end - -return { - init = init, - isNaN = isNaN, - isInfinity = isInfinity, - validateNumber = validateNumber, - sanitizeNumber = sanitizeNumber, - validateInteger = validateInteger, - validatePercentage = validatePercentage, - validateOpacity = validateOpacity, - validateDegrees = validateDegrees, - validateCoordinate = validateCoordinate, - validateDimension = validateDimension, - validateEnum = validateEnum, - validateRange = validateRange, - validateType = validateType, -} diff --git a/libs/flexlove/modules/PathValidator.lua b/libs/flexlove/modules/PathValidator.lua deleted file mode 100644 index b3ba0e81..00000000 --- a/libs/flexlove/modules/PathValidator.lua +++ /dev/null @@ -1,198 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Path sanitization, validation, and file-extension helpers. --- Uses love.filesystem when available (optional) for existence checks. - ---- Normalize a file path for consistent cache keys ----@param path string File path to normalize ----@return string Normalized path -local function normalizePath(path) - path = path:match("^%s*(.-)%s*$") - path = path:gsub("\\", "/") - path = path:gsub("/+", "/") - return path -end - ---- Sanitize a file path ---- @param path string Path to sanitize ---- @return string Sanitized path -local function sanitizePath(path) - if path == nil then - return "" - end - path = tostring(path) - - -- Trim whitespace - path = path:match("^%s*(.-)%s*$") or "" - - -- Normalize separators to forward slash - path = path:gsub("\\", "/") - - -- Remove duplicate slashes - path = path:gsub("/+", "/") - - -- Remove trailing slash (except for root) - if #path > 1 and path:sub(-1) == "/" then - path = path:sub(1, -2) - end - - return path -end - ---- Check if a path is safe (no traversal attacks) ---- @param path string Path to check ---- @param baseDir string? Base directory to check against (optional) ---- @return boolean, string? Returns true if safe, or false with reason -local function isPathSafe(path, baseDir) - if path == nil or path == "" then - return false, "Path is empty" - end - - -- Sanitize the path - path = sanitizePath(path) - - -- Check for suspicious patterns - if path:match("%.%.") then - return false, "Path contains '..' (parent directory reference)" - end - - -- Check for null bytes - if path:match("%z") then - return false, "Path contains null bytes" - end - - -- Check for encoded traversal attempts (including double-encoding) - local lowerPath = path:lower() - if - lowerPath:match("%%2e") - or lowerPath:match("%%2f") - or lowerPath:match("%%5c") - or lowerPath:match("%%252e") - or lowerPath:match("%%252f") - or lowerPath:match("%%255c") - then - return false, "Path contains URL-encoded directory separators" - end - - -- If baseDir is provided, ensure path is within it - if baseDir then - baseDir = sanitizePath(baseDir) - - -- For relative paths, prepend baseDir - local fullPath = path - if not path:match("^/") and not path:match("^%a:") then - fullPath = baseDir .. "/" .. path - end - fullPath = sanitizePath(fullPath) - - -- Check if fullPath starts with baseDir - if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then - return false, "Path is outside allowed directory" - end - end - - return true, nil -end - ---- Validate a file path with comprehensive checks ---- @param path string Path to validate ---- @param options table? Validation options ---- @return boolean, string? Returns true if valid, or false with error message -local function validatePath(path, options) - options = options or {} - - -- Check path is not nil/empty - if path == nil or path == "" then - return false, "Path is empty" - end - - path = tostring(path) - - -- Check maximum length - local maxLength = options.maxLength or 4096 - if #path > maxLength then - return false, string.format("Path exceeds maximum length of %d characters", maxLength) - end - - -- Sanitize path - path = sanitizePath(path) - - -- Check for safety (traversal attacks) - local safe, reason = isPathSafe(path, options.baseDir) - if not safe then - return false, reason - end - - -- Check allowed extensions - if options.allowedExtensions then - local ext = path:match("%.([^%.]+)$") - if not ext then - return false, "Path has no file extension" - end - - ext = ext:lower() - local allowed = false - for _, allowedExt in ipairs(options.allowedExtensions) do - if ext == allowedExt:lower() then - allowed = true - break - end - end - - if not allowed then - return false, string.format("File extension '%s' is not allowed", ext) - end - end - - -- Check if file must exist - if options.mustExist and love and love.filesystem then - local info = love.filesystem.getInfo(path) - if not info then - return false, "File does not exist" - end - end - - return true, nil -end - ---- Get file extension from path ---- @param path string File path ---- @return string? extension File extension (lowercase) or nil -local function getFileExtension(path) - if not path then - return nil - end - local ext = path:match("%.([^%.]+)$") - return ext and ext:lower() or nil -end - ---- Check if path has allowed extension ---- @param path string File path ---- @param allowedExtensions table Array of allowed extensions ---- @return boolean -local function hasAllowedExtension(path, allowedExtensions) - local ext = getFileExtension(path) - if not ext then - return false - end - - for _, allowedExt in ipairs(allowedExtensions) do - if ext == allowedExt:lower() then - return true - end - end - - return false -end - -return { - normalizePath = normalizePath, - sanitizePath = sanitizePath, - isPathSafe = isPathSafe, - validatePath = validatePath, - getFileExtension = getFileExtension, - hasAllowedExtension = hasAllowedExtension, -} diff --git a/libs/flexlove/modules/Performance.lua b/libs/flexlove/modules/Performance.lua deleted file mode 100644 index cf0458f0..00000000 --- a/libs/flexlove/modules/Performance.lua +++ /dev/null @@ -1,560 +0,0 @@ ----@class Performance ----@field enabled boolean ----@field hudEnabled boolean ----@field hudToggleKey string ----@field hudPosition {x: number, y: number} ----@field warningThresholdMs number ----@field criticalThresholdMs number ----@field logToConsole boolean ----@field logWarnings boolean ----@field warningsEnabled boolean ----@field _ErrorHandler table? ----@field _timers table ----@field _metrics table ----@field _lastMetricsCleanup number ----@field _frameMetrics table ----@field _memoryMetrics table ----@field _warnings table ----@field _lastFrameStart number? ----@field _shownWarnings table ----@field _memoryProfiler table -local Performance = {} -Performance.__index = Performance - ----@type Performance|nil -local instance = nil - -local METRICS_CLEANUP_INTERVAL = 30 -local METRICS_RETENTION_TIME = 10 -local MAX_METRICS_COUNT = 500 -local CORE_METRICS = { frame = true, layout = true, render = true } - ----@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}? ----@param deps {ErrorHandler: ErrorHandler} ----@return Performance -function Performance.init(config, deps) - if instance == nil then - local self = setmetatable({}, Performance) - - -- Configuration - self.enabled = config and config.enabled or false - self.hudEnabled = config and config.hudEnabled or false - self.hudToggleKey = config and config.hudToggleKey or "f3" - self.hudPosition = config and config.hudPosition or { x = 10, y = 10 } - self.warningThresholdMs = config and config.warningThresholdMs or 13.0 - self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67 - self.logToConsole = config and config.logToConsole or false - self.logWarnings = config and config.logWarnings or true - self.warningsEnabled = config and config.warningsEnabled or true - - self._timers = {} - self._metrics = {} - self._lastMetricsCleanup = 0 - self._frameMetrics = { - frameCount = 0, - totalTime = 0, - lastFrameTime = 0, - minFrameTime = math.huge, - maxFrameTime = 0, - fps = 0, - lastFpsUpdate = 0, - fpsUpdateInterval = 0.5, - } - self._memoryMetrics = { - current = 0, - peak = 0, - gcCount = 0, - lastGcCheck = 0, - } - self._warnings = {} - self._lastFrameStart = nil - self._shownWarnings = {} - self._memoryProfiler = { - enabled = config and config.memoryProfiling or false, - sampleInterval = 60, - framesSinceLastSample = 0, - samples = {}, - maxSamples = 20, - monitoredTables = {}, - } - self._ErrorHandler = deps and deps.ErrorHandler - instance = self - end - return instance -end - ---- Toggle HUD visibility -function Performance:toggleHUD() - self.hudEnabled = not self.hudEnabled -end - -function Performance:startTimer(name) - if not self.enabled then - return - end - self._timers[name] = love.timer.getTime() -end - -function Performance:stopTimer(name) - if not self.enabled then - return nil - end - - local startTime = self._timers[name] - if not startTime then - -- Silently return nil if timer wasn't started - -- This can happen legitimately when Performance is toggled mid-frame - -- or when layout functions have early returns - return nil - end - - local elapsed = (love.timer.getTime() - startTime) * 1000 - self._timers[name] = nil - - -- Update metrics - if not self._metrics[name] then - self._metrics[name] = { - total = 0, - count = 0, - min = math.huge, - max = 0, - average = 0, - lastUsed = love.timer.getTime(), - } - end - - local m = self._metrics[name] - m.total = m.total + elapsed - m.count = m.count + 1 - m.min = math.min(m.min, elapsed) - m.max = math.max(m.max, elapsed) - m.average = m.total / m.count - m.lastUsed = love.timer.getTime() - - -- Check for warnings - if elapsed > self.criticalThresholdMs then - self:_addWarning(name, elapsed, "critical") - elseif elapsed > self.warningThresholdMs then - self:_addWarning(name, elapsed, "warning") - end - - if self.logToConsole then - -- Use ErrorHandler if available, otherwise fall back to print - if self._ErrorHandler and self._ErrorHandler.warn then - self._ErrorHandler:warn("Performance", "PERF_001", { - metric = name, - elapsed = string.format("%.3fms", elapsed), - }) - else - print(string.format("[Performance] %s: %.3fms", name, elapsed)) - end - end - - return elapsed -end - ---- Update with actual delta time from LÖVE (call from love.update) ----@param dt number Delta time in seconds -function Performance:updateDeltaTime(dt) - if not self.enabled then - return - end - local now = love.timer.getTime() - if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then - if dt > 0 then - self._frameMetrics.fps = math.floor(1 / dt + 0.5) - end - self._frameMetrics.lastFpsUpdate = now - end -end - ---- Start frame timing (call at beginning of frame) -function Performance:startFrame() - if not self.enabled then - return - end - self._lastFrameStart = love.timer.getTime() - self:_updateMemory() -end - -function Performance:endFrame() - if not self.enabled or not self._lastFrameStart then - return - end - - local now = love.timer.getTime() - local frameTime = (now - self._lastFrameStart) * 1000 - - self._frameMetrics.lastFrameTime = frameTime - self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime - self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1 - self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime) - self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime) - - if frameTime > self.criticalThresholdMs then - self:_addWarning("frame", frameTime, "critical") - end - - self:updateMemoryProfiling() - - -- Periodic metrics cleanup - if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then - local cleanupTime = now - METRICS_RETENTION_TIME - for name, data in pairs(self._metrics) do - if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then - self._metrics[name] = nil - end - end - self._lastMetricsCleanup = now - end - - -- Enforce max metrics limit - local metricsCount = 0 - for _ in pairs(self._metrics) do - metricsCount = metricsCount + 1 - end - - if metricsCount > MAX_METRICS_COUNT then - local sortedMetrics = {} - for name, data in pairs(self._metrics) do - if not CORE_METRICS[name] then - table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 }) - end - end - - table.sort(sortedMetrics, function(a, b) - return a.lastUsed < b.lastUsed - end) - - local toRemove = metricsCount - MAX_METRICS_COUNT - for i = 1, math.min(toRemove, #sortedMetrics) do - self._metrics[sortedMetrics[i].name] = nil - end - end -end - ---- Update memory metrics -function Performance:_updateMemory() - if not self.enabled then - return - end - - local memKb = collectgarbage("count") - self._memoryMetrics.current = memKb - self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb) - - local now = love.timer.getTime() - if now - self._memoryMetrics.lastGcCheck >= 1.0 then - self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1 - self._memoryMetrics.lastGcCheck = now - end -end - ---- Add a performance warning (private) ---- @param name string Metric name ---- @param value number Metric value ---- @param level "warning"|"critical" Warning level -function Performance:_addWarning(name, value, level) - if not self.logWarnings then - return - end - - local warning = { - name = name, - value = value, - level = level, - time = love.timer.getTime(), - } - - table.insert(self._warnings, warning) - - if #self._warnings > 100 then - table.remove(self._warnings, 1) - end - - if self.logToConsole or self.warningsEnabled then - local warningKey = name .. "_" .. level - local lastWarningTime = self._shownWarnings[warningKey] or 0 - local now = love.timer.getTime() - - if now - lastWarningTime >= 60 then - if self._ErrorHandler and self._ErrorHandler.warn then - local code = level == "critical" and "PERF_002" or "PERF_001" - - self._ErrorHandler:warn("Performance", code, { - metric = name, - value = string.format("%.2fms", value), - threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs, - }) - end - - self._shownWarnings[warningKey] = now - end - end -end - ---- Render performance HUD ---- @param x number? X position (default: 10) ---- @param y number? Y position (default: 10) -function Performance:renderHUD(x, y) - if not self.hudEnabled then - return - end - - x = x or self.hudPosition.x - y = y or self.hudPosition.y - - self:_updateMemory() - - local fm = self._frameMetrics - local mm = self._memoryMetrics - - love.graphics.setColor(0, 0, 0, 0.8) - love.graphics.rectangle("fill", x, y, 300, 220) - - love.graphics.setColor(1, 1, 1, 1) - local lineHeight = 18 - local currentY = y + 10 - - -- FPS - local fpsColor = { 1, 1, 1 } - if fm.lastFrameTime > self.criticalThresholdMs then - fpsColor = { 1, 0, 0 } - elseif fm.lastFrameTime > self.warningThresholdMs then - fpsColor = { 1, 1, 0 } - end - love.graphics.setColor(fpsColor) - love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY) - currentY = currentY + lineHeight - - love.graphics.setColor(1, 1, 1, 1) - local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0 - love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY) - currentY = currentY + lineHeight - love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY) - currentY = currentY + lineHeight - - local currentMb = mm.current / 1024 - local peakMb = mm.peak / 1024 - love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY) - currentY = currentY + lineHeight - - local metricsCount = 0 - for _ in pairs(self._metrics) do - metricsCount = metricsCount + 1 - end - local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 } - love.graphics.setColor(metricsColor) - love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY) - currentY = currentY + lineHeight + 5 - - -- Top timings - love.graphics.setColor(1, 1, 1, 1) - local sortedMetrics = {} - for name, data in pairs(self._metrics) do - table.insert(sortedMetrics, { name = name, average = data.average }) - end - table.sort(sortedMetrics, function(a, b) - return a.average > b.average - end) - - love.graphics.print("Top Timings:", x + 10, currentY) - currentY = currentY + lineHeight - - for i = 1, math.min(5, #sortedMetrics) do - local m = sortedMetrics[i] - love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY) - currentY = currentY + lineHeight - end - - if #self._warnings > 0 then - love.graphics.setColor(1, 0.5, 0, 1) - love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY) - end -end - ---- Handle keyboard input for HUD toggle ---- @param key string Key pressed -function Performance:keypressed(key) - if key == self.hudToggleKey then - self:toggleHUD() - end -end - ---- Log a performance warning (only once per warning key) ---- @param warningKey string Unique key for this warning type ---- @param module string Module name (e.g., "LayoutEngine", "Element") ---- @param message string Warning message ---- @param details table? Additional details ---- @param suggestion string? Optimization suggestion -function Performance:logWarning(warningKey, module, message, details, suggestion) - if not self.warningsEnabled then - return - end - - if self._shownWarnings[warningKey] then - return - end - - self._shownWarnings[warningKey] = true - - local count = 0 - for _ in pairs(self._shownWarnings) do - count = count + 1 - end - if count > 1000 then - self._shownWarnings = { [warningKey] = true } - end - - if self._ErrorHandler and self._ErrorHandler.warn then - self._ErrorHandler:warn(module, "PERF_001", details or {}) - end -end - ---- Track a counter metric (increments per frame) ---- @param name string Counter name ---- @param value number? Value to add (default: 1) -function Performance:incrementCounter(name, value) - if not self.enabled then - return - end - - value = value or 1 - - if not self._metrics[name] then - self._metrics[name] = { - total = 0, - count = 0, - min = math.huge, - max = 0, - average = 0, - frameValue = 0, - lastUsed = love.timer.getTime(), - } - end - - local m = self._metrics[name] - m.frameValue = (m.frameValue or 0) + value - m.lastUsed = love.timer.getTime() -end - ---- Reset frame counters (call at end of frame) -function Performance:resetFrameCounters() - if not self.enabled then - return - end - - local now = love.timer.getTime() - local toRemove = {} - - for name, data in pairs(self._metrics) do - if data.frameValue then - if data.frameValue > 0 then - data.total = data.total + data.frameValue - data.count = data.count + 1 - data.min = math.min(data.min, data.frameValue) - data.max = math.max(data.max, data.frameValue) - data.average = data.total / data.count - data.lastUsed = now - end - - data.frameValue = 0 - - if data.count == 0 and not CORE_METRICS[name] then - table.insert(toRemove, name) - end - end - end - - for _, name in ipairs(toRemove) do - self._metrics[name] = nil - end -end - ---- Register a table for memory leak monitoring ---- @param name string Friendly name for the table ---- @param tableRef table Reference to the table to monitor -function Performance:registerTableForMonitoring(name, tableRef) - self._memoryProfiler.monitoredTables[name] = tableRef -end - -function Performance:_sampleMemory() - local sample = { - time = love.timer.getTime(), - memory = collectgarbage("count") / 1024, -- MB - tableSizes = {}, - } - local function getTableSize(tbl) - local count = 0 - for _ in pairs(tbl) do - count = count + 1 - end - return count - end - - for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do - sample.tableSizes[name] = getTableSize(tableRef) - end - - table.insert(self._memoryProfiler.samples, sample) - - -- Keep only maxSamples - if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then - table.remove(self._memoryProfiler.samples, 1) - end - - -- Check for memory leaks (consistent growth) - if #self._memoryProfiler.samples >= 5 then - for name, _ in pairs(self._memoryProfiler.monitoredTables) do - local sizes = {} - for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do - table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name]) - end - - -- Check if table is consistently growing - local growing = true - for i = 2, #sizes do - if sizes[i] <= sizes[i - 1] then - growing = false - break - end - end - - if growing and sizes[#sizes] > sizes[1] * 1.5 then - self:_addWarning("memory_leak", sizes[#sizes], "warning") - - if not self._shownWarnings[name] then - local message = string.format("Table '%s' growing consistently", name) - if self._ErrorHandler and self._ErrorHandler.warn then - self._ErrorHandler:warn("Performance", "MEM_001", { - table = name, - initialSize = sizes[1], - currentSize = sizes[#sizes], - growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100), - }) - end - - self._shownWarnings[name] = true - end - elseif not growing then - self._shownWarnings[name] = nil - end - end - end -end - ---- Update memory profiling (call from endFrame) -function Performance:updateMemoryProfiling() - if not self._memoryProfiler.enabled then - return - end - - self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1 - - if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then - self:_sampleMemory() - self._memoryProfiler.framesSinceLastSample = 0 - end -end - -return Performance diff --git a/libs/flexlove/modules/PropertySchema.lua b/libs/flexlove/modules/PropertySchema.lua deleted file mode 100644 index d2fe5b43..00000000 --- a/libs/flexlove/modules/PropertySchema.lua +++ /dev/null @@ -1,505 +0,0 @@ --- modules/PropertySchema.lua --- --- Declarative source of truth for every Element prop. --- --- Each entry describes one prop that Element.new / Element:setProperty currently --- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven --- setProperty dispatch) read this metadata instead of hardcoding property names. --- --- Design constraints (locked — tasks 03/05 depend on this API): --- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler. --- Normalizers/validators are small, dependency-free closures so the module is --- unit-testable standalone. Color/^/unit/enum *defaults* that require those --- modules are left as `nil` here and applied by construction-time special --- handlers in Task 03; only defaults expressible as literals are stored. --- * O(1) lookup — `get(name)` is a single table index into a pre-built registry; --- no per-call construction. --- * Additive — `define(specs)` merges entries by name so build profiles can --- extend/override without rebuilding the whole table. --- --- Metadata shape per prop (all fields present, false/nil when not applicable): --- type string — type tag for tooling ("number"|"string"|"boolean"| --- "table"|"function"|"color"|"any") --- default any|nil — literal default value applied when prop is absent --- normalizer fn|nil — pure fn(value) -> value; transforms input before --- storage (e.g. single-value padding -> 4-side table) --- validator fn|nil — pure fn(value) -> bool; returns false for invalid --- input (Task 03 warns + falls back on false) --- isDimension boolean — true for width/height: setProperty routes these --- through _resolveDimensionProperty (unit-string --- resolution + border-box sync). Other unit-accepting --- props (x/y/gap/padding/etc.) are resolved at --- construction via special handlers, NOT via this flag. --- affectsLayout boolean — true for props in the legacy setProperty --- `layoutProperties` table; setting one invalidates --- layout (matches baseline behavior exactly). --- syncsTheme boolean — true for props whose setProperty path must reach --- ThemeManager/Renderer (disabled/active/themeComponent) --- hasDeferred boolean — true for callbacks that have an `onDeferred` --- boolean companion prop (auto-wired by Task 03) --- storageKey string|nil— when set, the prop is stored on the element under --- this key instead of its own name (prop aliases, e.g. --- isDisabled -> stored as `disabled`) - -local PropertySchema = {} - ----@type table -local registry = {} - --- --------------------------------------------------------------------------- --- Pure normalizers (small + dependency-free; hot-pathed during construction) --- --------------------------------------------------------------------------- - ---- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough. ---- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`. -local function expandSides(value) - if value == nil then - return nil - end - if type(value) == "table" then - return value - end - return { top = value, right = value, bottom = value, left = value } -end - ---- Normalize flex direction aliases to internal enum names. ---- "row" -> "horizontal", "column" -> "vertical", ---- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse"; ---- everything else passes through. -local function normalizeFlexDirection(value) - if value == "row" then - return "horizontal" - elseif value == "column" then - return "vertical" - elseif value == "row-reverse" then - return "horizontal-reverse" - elseif value == "column-reverse" then - return "vertical-reverse" - end - return value -end - ---- Replicate Element.new's border-shape normalization (pure). ---- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no ---- truthy side remains. ---- * number / other truthy scalar: kept as-is. ---- * nil / false: nil. -local function normalizeBorder(value) - if value == nil or value == false then - return nil - end - if type(value) == "table" then - local function side(v) - if v == true then - return 1 - elseif type(v) == "number" then - return v - else - return false - end - end - local t = side(value.top) - local r = side(value.right) - local b = side(value.bottom) - local l = side(value.left) - if not (t or r or b or l) then - return nil - end - return { top = t, right = r, bottom = b, left = l } - end - return value -end - ---- Replicate Element.new's cornerRadius-shape normalization (pure). ---- * number: 0 -> nil, else the number. ---- * table: nil if all four sides are zero/absent, else fill zeros for absent sides. ---- * nil -> nil. -local function normalizeCornerRadius(value) - if value == nil then - return nil - end - if type(value) == "number" then - if value == 0 then - return nil - end - return value - end - if type(value) == "table" then - -- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all- - -- nil/false table collapses to nil; any present side — including 0 — yields - -- the 4-side table with zero-filled absent sides. - local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight - if not hasAny then - return nil - end - return { - topLeft = value.topLeft or 0, - topRight = value.topRight or 0, - bottomLeft = value.bottomLeft or 0, - bottomRight = value.bottomRight or 0, - } - end - return value -end - --- --------------------------------------------------------------------------- --- Pure validators (dependency-free; return boolean) --- --------------------------------------------------------------------------- - ---- Range validator factory: returns fn(v) -> bool. nil is treated as valid ---- (absence handling is the default mechanism's job). -local function rangeValidator(min, max) - return function(v) - if v == nil then - return true - end - return type(v) == "number" and v >= min and v <= max - end -end - ---- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may ---- be an array or a map of value->truthy). -local function enumValidator(set) - local lookup = {} - if type(set) == "table" then - for k, v in pairs(set) do - if type(k) == "number" then - lookup[v] = true - else - lookup[k] = true - end - end - end - return function(v) - if v == nil then - return true - end - return lookup[v] == true - end -end - ---- Boolean validator: nil is valid (absence); otherwise must be a boolean. -local function booleanValidator(v) - return v == nil or type(v) == "boolean" -end - --- --------------------------------------------------------------------------- --- Registry construction --- --------------------------------------------------------------------------- - ---- Build a fully-populated metadata entry, filling omitted fields with defaults. -local function entry(spec) - return { - type = spec.type or "any", - default = spec.default, - normalizer = spec.normalizer, - validator = spec.validator, - isDimension = spec.isDimension == true, - affectsLayout = spec.affectsLayout == true, - syncsTheme = spec.syncsTheme == true, - hasDeferred = spec.hasDeferred == true, - storageKey = spec.storageKey, - } -end - ---- Merge prop specs into the registry (additive; later entries override earlier). ----@param specs table map of prop-name -> spec ----@return table registry the live registry table (for chaining/inspection) -function PropertySchema.define(specs) - for name, spec in pairs(specs) do - registry[name] = entry(spec) - end - return registry -end - ---- O(1) metadata lookup. ----@param name string prop name ----@return table|nil metadata nil for unknown props (no error) -function PropertySchema.get(name) - return registry[name] -end - ---- Return the live registry (for inspection / coverage assertions only — not for ---- per-call construction). ----@return table -function PropertySchema.all() - return registry -end - ---- True if a prop is registered. ----@param name string ----@return boolean -function PropertySchema.has(name) - return registry[name] ~= nil -end - ---- True if setting this prop invalidates layout (legacy `layoutProperties` set). ---- O(1) registry lookup — no per-call table construction. Unknown props return false, ---- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly. ----@param name string prop name ----@return boolean -function PropertySchema.affectsLayout(name) - local meta = registry[name] - return meta ~= nil and meta.affectsLayout == true -end - ---- True for dimension props (width/height) that `setProperty` routes through ---- `_resolveDimensionProperty` (unit-string resolution + border-box sync). ---- O(1) registry lookup — no per-call table construction. Unknown props return false, ---- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly. ----@param name string prop name ----@return boolean -function PropertySchema.isDimension(name) - local meta = registry[name] - return meta ~= nil and meta.isDimension == true -end - ---- True for props whose setProperty path must reach ThemeManager/Renderer ---- (disabled/active/themeComponent). O(1) registry lookup — no per-call table ---- construction. Unknown props return false, matching a legacy nil-lookup exactly. ----@param name string prop name ----@return boolean -function PropertySchema.syncsTheme(name) - local meta = registry[name] - return meta ~= nil and meta.syncsTheme == true -end - --- --------------------------------------------------------------------------- --- Default schema (covers every prop handled in Element.new lines 259-1909 and --- Element:setProperty lines 4291-4417 of the Task-01 baseline). --- --------------------------------------------------------------------------- -local function defineDefaults() - PropertySchema.define({ - -- ------------------------------------------------------------------ identity - id = { type = "string" }, - userdata = { type = "any" }, - parent = { type = "table", affectsLayout = true }, - children = { type = "table" }, - - -- ------------------------------------------------------------------ callbacks - onEvent = { type = "function", hasDeferred = true }, - onFocus = { type = "function", hasDeferred = true }, - onBlur = { type = "function", hasDeferred = true }, - onTextInput = { type = "function", hasDeferred = true }, - onTextChange = { type = "function", hasDeferred = true }, - onEnter = { type = "function", hasDeferred = true }, - onCreate = { type = "function", hasDeferred = true }, - onTouchEvent = { type = "function", hasDeferred = true }, - onGesture = { type = "function", hasDeferred = true }, - onImageLoad = { type = "function", hasDeferred = true }, - onImageError = { type = "function", hasDeferred = true }, - - -- Deferred companion flags (stored directly; no further Deferred companion) - onEventDeferred = { type = "boolean", default = false }, - onFocusDeferred = { type = "boolean", default = false }, - onBlurDeferred = { type = "boolean", default = false }, - onTextInputDeferred = { type = "boolean", default = false }, - onTextChangeDeferred = { type = "boolean", default = false }, - onEnterDeferred = { type = "boolean", default = false }, - onCreateDeferred = { type = "boolean", default = false }, - onTouchEventDeferred = { type = "boolean", default = false }, - onGestureDeferred = { type = "boolean", default = false }, - onImageLoadDeferred = { type = "boolean", default = false }, - onImageErrorDeferred = { type = "boolean", default = false }, - - -- focus / touch behavior - dropFocusOnSelection = { type = "boolean" }, - customDraw = { type = "function" }, - touchEnabled = { type = "boolean", default = true }, - multiTouchEnabled = { type = "boolean", default = false }, - - -- ------------------------------------------------------------------ theme - theme = { type = "table" }, - themeComponent = { type = "string", syncsTheme = true }, - disabled = { type = "boolean", default = false, syncsTheme = true }, - isDisabled = { - type = "boolean", - default = false, - syncsTheme = true, - storageKey = "disabled", - }, - active = { type = "boolean", default = false, syncsTheme = true }, - disableHighlight = { type = "boolean" }, - themeStateLock = { type = "boolean" }, - themeComponentDisabledStates = { type = "table" }, - scaleCorners = { type = "boolean" }, - scalingAlgorithm = { type = "string" }, - contentAutoSizingMultiplier = { type = "table" }, - contentBlur = { type = "table" }, - backdropBlur = { type = "table" }, - - -- ------------------------------------------------------------------ text editing - editable = { type = "boolean", default = false }, - multiline = { type = "boolean", default = false }, - passwordMode = { type = "boolean", default = false }, - textWrap = { type = "string" }, -- default computed from multiline - maxLines = { type = "number" }, - maxLength = { type = "number" }, - placeholder = { type = "string" }, - inputType = { type = "string", default = "text" }, - textOverflow = { type = "string", default = "clip" }, - scrollable = { type = "boolean" }, -- default = multiline - autoGrow = { type = "boolean" }, -- default = multiline - selectOnFocus = { type = "boolean", default = false }, - cursorColor = { type = "color" }, - selectionColor = { type = "color" }, - cursorBlinkRate = { type = "number", default = 0.5 }, - text = { type = "string" }, - textAlign = { - type = "string", - default = "start", - validator = enumValidator({ "start", "center", "end", "justify" }), - }, - -- textAlignVertical is a derived storage field split out from textAlign - -- (bindVisualState resolves table/compound-string input into H + V). Its - -- validator is exposed for bindVisualState to validate the V component; the - -- prop itself stays in SPECIAL_PROPS because compound parsing needs - -- ErrorHandler warnings (schema is pure-Lua, cannot warn). - textAlignVertical = { - type = "string", - default = "start", - validator = enumValidator({ "start", "center", "end" }), - }, - textColor = { type = "color" }, - fontFamily = { type = "string" }, - textSize = { type = "any" }, -- number | preset string; resolved by special handler - minTextSize = { type = "number" }, - maxTextSize = { type = "number" }, - autoScaleText = { type = "boolean", default = true }, - - -- ------------------------------------------------------------------ dimensions / box model - width = { type = "any", isDimension = true, affectsLayout = true }, - height = { type = "any", isDimension = true, affectsLayout = true }, - x = { type = "any", affectsLayout = false }, - y = { type = "any", affectsLayout = false }, - minWidth = { type = "any" }, - maxWidth = { type = "any" }, - minHeight = { type = "any" }, - maxHeight = { type = "any" }, - gap = { type = "any", affectsLayout = true }, - padding = { - type = "any", - affectsLayout = true, - normalizer = expandSides, - }, - margin = { - type = "any", - affectsLayout = true, - normalizer = expandSides, - }, - flexDirection = { - type = "string", - default = "horizontal", - affectsLayout = true, - normalizer = normalizeFlexDirection, - }, - flexWrap = { type = "string", default = "nowrap", affectsLayout = true }, - justifyContent = { type = "string", default = "flex-start", affectsLayout = true }, - alignItems = { type = "string", default = "stretch", affectsLayout = true }, - alignContent = { type = "string", default = "stretch", affectsLayout = true }, - positioning = { type = "string", default = "relative", affectsLayout = true }, - gridRows = { type = "number", affectsLayout = true }, - gridColumns = { type = "number", affectsLayout = true }, - top = { type = "any", affectsLayout = true }, - right = { type = "any", affectsLayout = true }, - bottom = { type = "any", affectsLayout = true }, - left = { type = "any", affectsLayout = true }, - columnGap = { type = "any" }, - rowGap = { type = "any" }, - flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis - flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) }, - flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) }, - flexBasis = { type = "any", default = "auto" }, - alignSelf = { type = "string", default = "auto" }, - justifySelf = { type = "string" }, - z = { type = "number", default = 0 }, - tabIndex = { type = "number" }, - - -- ------------------------------------------------------------------ border / background / visual - border = { type = "any", normalizer = normalizeBorder }, - borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler - backgroundColor = { type = "color" }, -- default transparent via special handler - opacity = { - type = "number", - default = 1, - validator = rangeValidator(0, 1), - }, - visibility = { type = "string", default = "visible" }, - display = { - type = "boolean", - default = true, - validator = booleanValidator, - }, - transform = { type = "table" }, - cornerRadius = { type = "any", normalizer = normalizeCornerRadius }, - - -- ------------------------------------------------------------------ image - imagePath = { type = "string" }, - image = { type = "table" }, - objectFit = { - type = "string", - default = "fill", - validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }), - }, - objectPosition = { type = "string", default = "center center" }, - imageOpacity = { - type = "number", - default = 1, - validator = rangeValidator(0, 1), - }, - imageRepeat = { - type = "string", - default = "no-repeat", - validator = enumValidator({ - "no-repeat", - "repeat", - "repeat-x", - "repeat-y", - "space", - "round", - }), - }, - imageTint = { type = "color" }, - - -- ------------------------------------------------------------------ scroll / scrollbar - overflow = { type = "string" }, - overflowX = { type = "string" }, - overflowY = { type = "string" }, - scrollbarWidth = { type = "number" }, - scrollbarColor = { type = "color" }, - scrollbarTrackColor = { type = "color" }, - scrollbarRadius = { type = "number" }, - scrollbarPadding = { type = "number" }, - scrollSpeed = { type = "number" }, - invertScroll = { type = "boolean" }, - smoothScrollEnabled = { type = "boolean" }, - scrollBarStyle = { type = "string" }, - scrollbarKnobOffset = { type = "number" }, - hideScrollbars = { type = "boolean" }, - scrollbarPlacement = { type = "string" }, - scrollbarBalance = { type = "number" }, - _scrollX = { type = "number", storageKey = "_scrollX" }, - _scrollY = { type = "number", storageKey = "_scrollY" }, - - -- ------------------------------------------------------------------ select - selectParent = { type = "table" }, - selectOption = { type = "table" }, - - -- ------------------------------------------------------------------ transition - transition = { type = "table", default = {} }, - }) -end - ---- (Re)populate the default schema. Idempotent: safe to call from Element.init ---- for build profiles that re-require the module. Returns the live registry. ----@return table registry -function PropertySchema.populate() - defineDefaults() - return registry -end - --- Auto-populate on require so the registry is ready without an explicit init call --- (pure module, no external deps — safe at load time). -PropertySchema.populate() - -return PropertySchema diff --git a/libs/flexlove/modules/Renderer.lua b/libs/flexlove/modules/Renderer.lua deleted file mode 100644 index 8265793e..00000000 --- a/libs/flexlove/modules/Renderer.lua +++ /dev/null @@ -1,1230 +0,0 @@ -local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8") - ----@class Renderer ----@field backgroundColor Color ----@field borderColor Color ----@field opacity number ----@field border {top:boolean, right:boolean, bottom:boolean, left:boolean} ----@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ----@field theme string? ----@field themeComponent string? ----@field _themeState string ----@field imagePath string? ----@field image love.Image? ----@field _loadedImage love.Image? ----@field objectFit string ----@field objectPosition string ----@field imageOpacity number ----@field contentBlur {intensity:number, quality:number}? ----@field backdropBlur {intensity:number, quality:number}? ----@field _blurInstance table? ----@field _element Element? ----@field _Color Color ----@field _RoundedRect table ----@field _NinePatch table ----@field _ImageRenderer table ----@field _ImageCache table ----@field _Theme table ----@field _Transform Transform ----@field _Blur Blur ----@field _utils table ----@field _FONT_CACHE table ----@field _TextAlign table ----@field _ErrorHandler ErrorHandler ----@field _Performance Performance? Performance module dependency -local Renderer = {} -Renderer.__index = Renderer - ---- Initialize module with shared dependencies ----@param deps table Dependencies {ErrorHandler, Performance} -function Renderer.init(deps) - Renderer._ErrorHandler = deps.ErrorHandler - Renderer._Performance = deps.Performance -end - ---- Create a new Renderer instance ----@param config table Configuration table with rendering properties ----@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, Transform, utils} -function Renderer.new(config, deps) - local Color = deps.Color - local ImageCache = deps.ImageCache - - local self = setmetatable({}, Renderer) - - -- Store dependencies for instance methods - self._Color = Color - self._RoundedRect = deps.RoundedRect - self._NinePatch = deps.NinePatch - self._ImageRenderer = deps.ImageRenderer - self._ImageCache = ImageCache - self._Theme = deps.Theme - self._Blur = deps.Blur - self._Transform = deps.Transform - self._utils = deps.utils - self._FONT_CACHE = deps.utils.FONT_CACHE - self._TextAlign = deps.utils.enums.TextAlign - self._TextAlignVertical = deps.utils.enums.TextAlignVertical - - -- Visual properties - self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0) - self.borderColor = config.borderColor or Color.new(0, 0, 0, 1) - self.opacity = config.opacity or 1 - - -- NOTE: border is intentionally NOT cached here. Renderer:draw resolves it from - -- element.border (source of truth) so retained-mode bare writes and - -- setProperty("border", ...) both take effect immediately. - - -- Corner radius - self.cornerRadius = config.cornerRadius - or { - topLeft = 0, - topRight = 0, - bottomLeft = 0, - bottomRight = 0, - } - - -- Theme properties - self.theme = config.theme - self.themeComponent = config.themeComponent - self._themeState = "normal" - - -- Image properties - self.imagePath = config.imagePath - self.image = config.image - self._loadedImage = nil - self.objectFit = config.objectFit or "fill" - self.objectPosition = config.objectPosition or "center center" - self.imageOpacity = config.imageOpacity or 1 - self.imageRepeat = config.imageRepeat or "no-repeat" - self.imageTint = config.imageTint - - -- Blur effects - self.contentBlur = config.contentBlur - self.backdropBlur = config.backdropBlur - self._blurInstance = nil - - -- Load image if path provided - if self.imagePath and not self.image then - local loadedImage = ImageCache.load(self.imagePath) - if loadedImage then - self._loadedImage = loadedImage - else - self._loadedImage = nil - end - elseif self.image then - self._loadedImage = self.image - else - self._loadedImage = nil - end - - return self -end - ---- Get or create blur instance for this element ----@return table|nil Blur instance or nil -function Renderer:getBlurInstance() - -- Determine quality from blur settings - local quality = "medium" - if self.contentBlur and self.contentBlur.quality then - quality = self.contentBlur.quality - elseif self.backdropBlur and self.backdropBlur.quality then - quality = self.backdropBlur.quality - end - - -- Map string quality to numeric quality (1-10) - local numericQuality = 5 -- default medium - if type(quality) == "string" then - if quality == "low" then - numericQuality = 3 - elseif quality == "medium" then - numericQuality = 5 - elseif quality == "high" then - numericQuality = 8 - end - elseif type(quality) == "number" then - numericQuality = quality - end - - -- Create or reuse blur instance - if not self._blurInstance or self._blurInstance.quality ~= numericQuality then - self._blurInstance = self._Blur.new({ quality = numericQuality }) - end - - return self._blurInstance -end - ---- Set theme state (normal, hover, pressed, disabled, active) ----@param state string The theme state -function Renderer:setThemeState(state) - self._themeState = state -end - ---- Execute a single core draw command (background, image, theme, borders). ---- Commands are plain tables: { type = "background"|"image"|"theme"|"borders", ... } ----@param cmd table Command table ----@param ctx table Resolved draw context -function Renderer:_executeDrawCommand(cmd, ctx) - if cmd.type == "background" then - local c = self._Color.new(cmd.color.r, cmd.color.g, cmd.color.b, cmd.color.a * ctx.opacity) - love.graphics.setColor(c:toRGBA()) - self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius) - elseif cmd.type == "image" then - -- Image value props (imageOpacity/imageRepeat/imageTint/objectFit/ - -- objectPosition) and imagePath are read from the element as the single - -- source of truth, so retained-mode bare writes (`element.imageOpacity = 0.5`), - -- the setImage* setters, and setProperty(...) are all immediately - -- consistent. The renderer's own config is a fallback for standalone - -- Renderer usage with a sparse element (mirrors `element.onEvent or - -- self.onEvent`); the integrated path always supplies an element whose - -- _applyProps-bound values take precedence. _loadedImage intentionally - -- remains on the renderer (the resolved love.Image from the Imageable load - -- pipeline). See TestRetainedPropertyConsistency. - local el = self._element - local imageOpacity = (el and el.imageOpacity) or self.imageOpacity - local imageRepeat = (el and el.imageRepeat) or self.imageRepeat - local imageTint = (el and el.imageTint) or self.imageTint - local objectFit = (el and el.objectFit) or self.objectFit - local objectPosition = (el and el.objectPosition) or self.objectPosition - local imagePath = (el and el.imagePath) or self.imagePath - if not self._loadedImage then - return - end - local img = self._loadedImage - local imageX = ctx.x + ctx.paddingLeft - local imageY = ctx.y + ctx.paddingTop - local finalOpacity = ctx.opacity * imageOpacity - local hasCornerRadius = false - if ctx.cornerRadius then - if type(ctx.cornerRadius) == "number" then - hasCornerRadius = ctx.cornerRadius > 0 - else - hasCornerRadius = ctx.cornerRadius.topLeft > 0 - or ctx.cornerRadius.topRight > 0 - or ctx.cornerRadius.bottomLeft > 0 - or ctx.cornerRadius.bottomRight > 0 - end - end - if hasCornerRadius then - local success, err = pcall(function() - love.graphics.stencil(function() - self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius) - end, "replace", 1) - love.graphics.setStencilTest("greater", 0) - end) - if not success then - if err and err:match("stencil") then - local cr = ctx.cornerRadius - local crStr = type(cr) == "number" and tostring(cr) - or string.format("TL:%d TR:%d BL:%d BR:%d", cr.topLeft, cr.topRight, cr.bottomLeft, cr.bottomRight) - Renderer._ErrorHandler:warn( - "Renderer", - "IMG_001", - { imagePath = imagePath or "unknown", cornerRadius = crStr, error = tostring(err) } - ) - hasCornerRadius = false - else - error(err, 2) - end - end - end - if imageRepeat and imageRepeat ~= "no-repeat" then - self._ImageRenderer.drawTiled( - img, - imageX, - imageY, - ctx.contentWidth, - ctx.contentHeight, - imageRepeat, - finalOpacity, - imageTint - ) - else - self._ImageRenderer.draw( - img, - imageX, - imageY, - ctx.contentWidth, - ctx.contentHeight, - objectFit, - objectPosition, - finalOpacity, - imageTint - ) - end - if hasCornerRadius then - love.graphics.setStencilTest() - end - elseif cmd.type == "theme" then - if not cmd.themeComponent then - return - end - local themeToUse = nil - if self.theme then - themeToUse = self._Theme.get(self.theme) - if not themeToUse then - pcall(function() - self._Theme.load(self.theme) - end) - themeToUse = self._Theme.get(self.theme) - end - else - themeToUse = self._Theme.getActive() - end - if not themeToUse then - return - end - local component = themeToUse.components[cmd.themeComponent] - if not component then - return - end - local state = self._themeState - if state and component.states and component.states[state] then - component = component.states[state] - end - local atlasToUse = component._loadedAtlas or themeToUse.atlas - if atlasToUse and component.regions then - local r = component.regions - if - r.topLeft - and r.topCenter - and r.topRight - and r.middleLeft - and r.middleCenter - and r.middleRight - and r.bottomLeft - and r.bottomCenter - and r.bottomRight - then - self._NinePatch.draw( - component, - atlasToUse, - ctx.x, - ctx.y, - ctx.borderBoxWidth, - ctx.borderBoxHeight, - ctx.opacity, - cmd.scaleCorners, - cmd.scalingAlgorithm - ) - end - end - elseif cmd.type == "borders" then - local border = cmd.border - if not border then - return - end - local bc = cmd.borderColor - local borderColorWithOpacity = self._Color.new(bc.r, bc.g, bc.b, bc.a * ctx.opacity) - love.graphics.setColor(borderColorWithOpacity:toRGBA()) - local bw, bh = ctx.borderBoxWidth, ctx.borderBoxHeight - if type(border) == "number" then - love.graphics.setLineWidth(border) - self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius) - love.graphics.setLineWidth(1) - else - local allBorders = border.top and border.bottom and border.left and border.right - local uniformWidth = allBorders - and type(border.top) == "number" - and border.top == border.right - and border.top == border.bottom - and border.top == border.left - if uniformWidth then - love.graphics.setLineWidth(border.top) - self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius) - love.graphics.setLineWidth(1) - else - if border.top then - love.graphics.setLineWidth(type(border.top) == "number" and border.top or 1) - love.graphics.line(ctx.x, ctx.y, ctx.x + bw, ctx.y) - end - if border.bottom then - love.graphics.setLineWidth(type(border.bottom) == "number" and border.bottom or 1) - love.graphics.line(ctx.x, ctx.y + bh, ctx.x + bw, ctx.y + bh) - end - if border.left then - love.graphics.setLineWidth(type(border.left) == "number" and border.left or 1) - love.graphics.line(ctx.x, ctx.y, ctx.x, ctx.y + bh) - end - if border.right then - love.graphics.setLineWidth(type(border.right) == "number" and border.right or 1) - love.graphics.line(ctx.x + bw, ctx.y, ctx.x + bw, ctx.y + bh) - end - love.graphics.setLineWidth(1) - end - end - end -end - ---- Build the render command buffer: resolve draw properties once from the ---- element (source of truth) and return a flat command list + draw context. ----@param element table Element instance ----@param backdropCanvas table|nil ----@return table cmds, table ctx Command list and resolved context -function Renderer:_buildCommands(element, backdropCanvas) - local opacity = element.opacity ~= nil and element.opacity or 1 - local backgroundColor = element.backgroundColor or self._Color.new(0, 0, 0, 0) - local borderColor = element.borderColor or self._Color.new(0, 0, 0, 1) - local cornerRadius = element.cornerRadius ~= nil and element.cornerRadius or nil - local themeComponent = element.themeComponent - local border = element.border - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - - -- Handle opacity during animation - local drawBackgroundColor = backgroundColor - if element.animation then - local anim = element.animation:interpolate() - if anim.opacity then - drawBackgroundColor = self._Color.new(backgroundColor.r, backgroundColor.g, backgroundColor.b, anim.opacity) - end - end - - -- Build resolved context (shared by all commands — eliminates per-method params) - local ctx = { - x = element.x, - y = element.y, - opacity = opacity, - cornerRadius = cornerRadius, - borderBoxWidth = borderBoxWidth, - borderBoxHeight = borderBoxHeight, - paddingLeft = element.padding.left, - paddingTop = element.padding.top, - contentWidth = element.width, - contentHeight = element.height, - backdropCanvas = backdropCanvas, - } - - -- Build command list (conditional — only emit layers that have data) - local cmds = {} - local n = 0 - - -- LAYER 0.5: backdrop blur (handled separately, not a command — needs canvas access) - if self.backdropBlur and self.backdropBlur.radius > 0 then - n = n + 1 - cmds[n] = { type = "backdropBlur", radius = self.backdropBlur.radius } -- executed before background - end - - -- LAYER 1: background - n = n + 1 - cmds[n] = { type = "background", color = drawBackgroundColor } - - -- LAYER 1.5: image (always emit; _executeCommand early-exits if no image) - n = n + 1 - cmds[n] = { type = "image" } - - -- LAYER 2: theme 9-patch - n = n + 1 - cmds[n] = { - type = "theme", - themeComponent = themeComponent, - scaleCorners = element.scaleCorners, - scalingAlgorithm = element.scalingAlgorithm, - } - - -- LAYER 3: borders - n = n + 1 - cmds[n] = { type = "borders", borderColor = borderColor, border = border } - - -- LAYER 4: text (cursor, selection, placeholder, password masking) - n = n + 1 - cmds[n] = { type = "text" } - - -- LAYER 4.5: custom draw callback (if provided) - if element.customDraw then - n = n + 1 - cmds[n] = { type = "customDraw" } - end - - -- NOTE: pressed-state overlay (former Layer 5) is now owned by the Clickable - -- behavior's onDraw, dispatched from Element:draw. The renderer no longer - -- branches on element.onEvent for press feedback. - - return cmds, ctx -end - ---- Execute a special render command (backdropBlur, customDraw, pressedState). ---- These interact with love.graphics state in non-uniform ways and are handled separately ---- from the core draw commands (background/image/theme/borders). ----@param cmd table Command table ----@param ctx table Resolved draw context -function Renderer:_executeSpecialCommand(cmd, ctx) - if cmd.type == "backdropBlur" then - if ctx.backdropCanvas then - local blurInstance = self:getBlurInstance() - if blurInstance then - local eid = self._element and self._element.id and self._element.id ~= "" and self._element.id or nil - blurInstance:applyBackdropCached( - cmd.radius, - ctx.x, - ctx.y, - ctx.borderBoxWidth, - ctx.borderBoxHeight, - ctx.backdropCanvas, - eid - ) - end - end - elseif cmd.type == "text" then - self:drawText(self._element) - elseif cmd.type == "customDraw" then - love.graphics.push() - love.graphics.setColor(1, 1, 1, 1) - self._element.customDraw(self._element) - love.graphics.pop() - end -end - ---- Main draw method - renders all visual layers via command buffer. ----@param element Element The parent Element instance ----@param backdropCanvas table|nil Backdrop canvas for backdrop blur -function Renderer:draw(element, backdropCanvas) - self._element = element -- cache for customDraw/pressedState - - if not element then - Renderer._ErrorHandler:warn("Renderer", "SYS_002", { method = "draw" }) - return - end - - -- Start performance timing - local elementId - if Renderer._Performance and Renderer._Performance.enabled then - elementId = element.id or "unnamed" - Renderer._Performance:startTimer("render_" .. elementId) - Renderer._Performance:incrementCounter("draw_calls", 1) - end - - -- Early exit if element is invisible (optimization) - if element.opacity ~= nil and element.opacity <= 0 then - if Renderer._Performance and Renderer._Performance.enabled and elementId then - Renderer._Performance:stopTimer("render_" .. elementId) - end - return - end - - -- Build command buffer + resolve draw context once - local cmds, ctx = self:_buildCommands(element, backdropCanvas) - - -- Apply transform if exists - local hasTransform = element.transform and self._Transform and not self._Transform.isIdentity(element.transform) - if hasTransform then - self._Transform.apply(element.transform, element.x, element.y, element.width, element.height) - end - - -- Execute all commands in order - for _, cmd in ipairs(cmds) do - -- Draw commands (background, image, theme, borders) use the core executor; - -- special commands (backdropBlur, customDraw, pressedState) are handled in _executeCommand. - local typ = cmd.type - if typ == "background" or typ == "image" or typ == "theme" or typ == "borders" then - self:_executeDrawCommand(cmd, ctx) - else - self:_executeSpecialCommand(cmd, ctx) - end - end - - -- Unapply transform if it was applied - if hasTransform then - self._Transform.unapply() - end - - -- Stop performance timing - if Renderer._Performance and Renderer._Performance.enabled and elementId then - Renderer._Performance:stopTimer("render_" .. elementId) - end -end - ---- Get font for element (resolves from theme or fontFamily) ----@param element table Reference to the parent Element instance ----@return love.Font -function Renderer:getFont(element) - return self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) -end - ---- Wrap a line of text based on element's textWrap mode ----@param element table Reference to the parent Element instance ----@param line string The line of text to wrap ----@param maxWidth number Maximum width for wrapping ----@return table Array of {text, startIdx, endIdx} -function Renderer:wrapLine(element, line, maxWidth) - -- UTF-8 support - local utf8 = UTF8 - - if not element.editable then - return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } - end - - local font = self:getFont(element) - local wrappedParts = {} - local currentLine = "" - local startIdx = 0 - - -- Helper function to extract a UTF-8 character by character index - local function getUtf8Char(str, charIndex) - local byteStart = utf8.offset(str, charIndex) - if not byteStart then - return "" - end - local byteEnd = utf8.offset(str, charIndex + 1) - if byteEnd then - return str:sub(byteStart, byteEnd - 1) - else - return str:sub(byteStart) - end - end - - if element.textWrap == "word" then - -- Tokenize into words and whitespace, preserving exact spacing - local tokens = {} - local pos = 1 - local lineLen = utf8.len(line) - - while pos <= lineLen do - -- Check if current position is whitespace - local char = getUtf8Char(line, pos) - if char:match("%s") then - -- Collect whitespace sequence - local wsStart = pos - while pos <= lineLen and getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "space", - text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wsStart - 1, - length = pos - wsStart, - }) - else - -- Collect word (non-whitespace sequence) - local wordStart = pos - while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do - pos = pos + 1 - end - table.insert(tokens, { - type = "word", - text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line), - startPos = wordStart - 1, - length = pos - wordStart, - }) - end - end - - -- Process tokens and wrap - local charPos = 0 -- Track our position in the original line - for _, token in ipairs(tokens) do - if token.type == "word" then - local testLine = currentLine .. token.text - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - -- Current line is full, wrap before this word - local currentLineLen = utf8.len(currentLine) - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + currentLineLen, - }) - startIdx = charPos - currentLine = token.text - charPos = charPos + token.length - - -- Check if the word itself is too long - if so, break it with character wrapping - if font:getWidth(token.text) > maxWidth then - local wordLen = utf8.len(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, j) - local testCharLine = charLine .. char - local charWidth = font:getWidth(testCharLine) - - if charWidth > maxWidth and charLine ~= "" then - table.insert(wrappedParts, { - text = charLine, - startIdx = charStartIdx, - endIdx = charStartIdx + utf8.len(charLine), - }) - charStartIdx = charStartIdx + utf8.len(charLine) - charLine = char - else - charLine = testCharLine - end - end - - currentLine = charLine - startIdx = charStartIdx - end - elseif width > maxWidth and currentLine == "" then - -- Word is too long to fit on a line by itself - use character wrapping - local wordLen = utf8.len(token.text) - local charLine = "" - local charStartIdx = startIdx - - for j = 1, wordLen do - local char = getUtf8Char(token.text, j) - local testCharLine = charLine .. char - local charWidth = font:getWidth(testCharLine) - - if charWidth > maxWidth and charLine ~= "" then - table.insert(wrappedParts, { - text = charLine, - startIdx = charStartIdx, - endIdx = charStartIdx + utf8.len(charLine), - }) - charStartIdx = charStartIdx + utf8.len(charLine) - charLine = char - else - charLine = testCharLine - end - end - - currentLine = charLine - startIdx = charStartIdx - charPos = charPos + token.length - else - currentLine = testLine - charPos = charPos + token.length - end - else - -- It's whitespace - add to current line - currentLine = currentLine .. token.text - charPos = charPos + token.length - end - end - else - -- Character wrapping - local lineLength = utf8.len(line) - for i = 1, lineLength do - local char = getUtf8Char(line, i) - local testLine = currentLine .. char - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - currentLine = char - startIdx = i - 1 - else - currentLine = testLine - end - end - end - - -- Add remaining text - if currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - end - - -- Ensure at least one part - if #wrappedParts == 0 then - table.insert(wrappedParts, { - text = "", - startIdx = 0, - endIdx = 0, - }) - end - - return wrappedParts -end - ---- Draw text content (includes text, cursor, selection, placeholder, password masking) ----@param element table Reference to the parent Element instance -function Renderer:drawText(element) - -- Update text layout if dirty (for multiline auto-grow) - if element._textEditor then - element._textEditor:_updateTextIfDirty(element) - element._textEditor:updateAutoGrowHeight(element) - end - - -- For editable elements, use TextEditor buffer; for non-editable, use text - local displayText = element._textEditor and element._textEditor:getText() or element.text - local isPlaceholder = false - - -- Show placeholder if editable and empty - if element.editable and (not displayText or displayText == "") and element.placeholder then - displayText = element.placeholder - isPlaceholder = true - end - - -- Apply password masking if enabled - if element.passwordMode and displayText and displayText ~= "" and not isPlaceholder then - local maskedText = string.rep("•", UTF8.len(displayText)) - displayText = maskedText - end - - if displayText and displayText ~= "" then - local textColor = isPlaceholder - and self._Color.new( - element.textColor.r * 0.5, - element.textColor.g * 0.5, - element.textColor.b * 0.5, - element.textColor.a * 0.5 - ) - or element.textColor - local textColorOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) - local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * textColorOpacity) - love.graphics.setColor(textColorWithOpacity:toRGBA()) - - local origFont = love.graphics.getFont() - if element.textSize then - -- Use cached font instead of creating new one every frame - local font = - self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) - love.graphics.setFont(font) - end - local font = love.graphics.getFont() - local textWidth = font:getWidth(displayText) - local textHeight = font:getHeight() - local tx, ty - - -- Text is drawn in the content box (inside padding) - -- For 9-patch components, use contentPadding if available - local textPaddingLeft = element.padding.left - local textPaddingTop = element.padding.top - local textAreaWidth = element.width - local textAreaHeight = element.height - - -- Check if we should use 9-patch contentPadding for text positioning - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local borderBoxHeight = element._borderBoxHeight - or (element.height + element.padding.top + element.padding.bottom) - - textPaddingLeft = scaledContentPadding.left - textPaddingTop = scaledContentPadding.top - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom - end - - local contentX = element.x + textPaddingLeft - local contentY = element.y + textPaddingTop - - -- Resolve horizontal and vertical alignment (new format with backward compatibility) - local hAlign = element.textAlignHorizontal or element.textAlign or self._TextAlign.START - local vAlign = element.textAlignVertical or self._TextAlignVertical.START - - -- Check if text wrapping is enabled - if element.textWrap and (element.textWrap == "word" or element.textWrap == "char" or element.textWrap == true) then - -- Use printf for wrapped text (horizontal alignment only) - local align = "left" - if hAlign == self._TextAlign.CENTER then - align = "center" - elseif hAlign == self._TextAlign.END then - align = "right" - elseif hAlign == self._TextAlign.JUSTIFY then - align = "justify" - end - - tx = contentX - ty = contentY - - -- Use printf with the available width for wrapping - love.graphics.printf(displayText, tx, ty, textAreaWidth, align) - else - -- Use regular print for non-wrapped text - -- Horizontal alignment - if hAlign == self._TextAlign.START then - tx = contentX - elseif hAlign == self._TextAlign.CENTER then - tx = contentX + (textAreaWidth - textWidth) / 2 - elseif hAlign == self._TextAlign.END then - tx = contentX + textAreaWidth - textWidth - 10 - else -- JUSTIFY or unknown - tx = contentX - end - - -- Vertical alignment - if vAlign == self._TextAlignVertical.START then - ty = contentY - elseif vAlign == self._TextAlignVertical.CENTER then - ty = contentY + (textAreaHeight - textHeight) / 2 - elseif vAlign == self._TextAlignVertical.END then - ty = contentY + textAreaHeight - textHeight - else - ty = contentY - end - - -- Apply scroll offset for editable single-line inputs - if element.editable and not element.multiline and element._textScrollX then - tx = tx - element._textScrollX - end - - -- Use scissor to clip text to content area for editable inputs - if element.editable and not element.multiline then - love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) - end - - love.graphics.print(displayText, tx, ty) - - -- Reset scissor - if element.editable and not element.multiline then - love.graphics.setScissor() - end - end - - -- Draw cursor for focused editable elements (even if text is empty) - if element._textEditor and element._textEditor:isFocused() and element._textEditor._cursorVisible then - local cursorColor = element.cursorColor or element.textColor - local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) - local cursorWithOpacity = - self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity) - love.graphics.setColor(cursorWithOpacity:toRGBA()) - - -- Calculate cursor position using TextEditor method - local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition(element) - local cursorX = contentX + cursorRelX - local cursorY = contentY + cursorRelY - local cursorHeight = textHeight - - -- Apply scroll offset for single-line inputs - if not element.multiline and element._textEditor._textScrollX then - cursorX = cursorX - element._textEditor._textScrollX - end - - -- Apply scissor for single-line editable inputs - if not element.multiline then - love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) - end - - -- Draw cursor line - love.graphics.rectangle("fill", cursorX, cursorY, 2, cursorHeight) - - -- Reset scissor - if not element.multiline then - love.graphics.setScissor() - end - end - - -- Draw selection highlight for editable elements - if element._textEditor and element._textEditor:isFocused() and element._textEditor:hasSelection() then - -- For editable elements, check TextEditor buffer instead of element.text - local textBuffer = element._textEditor:getText() - if textBuffer and textBuffer ~= "" then - local selStart, selEnd = element._textEditor:getSelection() - local selectionColor = element.selectionColor or self._Color.new(0.3, 0.5, 0.8, 0.5) - local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) - local selectionWithOpacity = - self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * elemOpacity) - - -- Get selection rectangles from TextEditor - local selectionRects = element._textEditor:_getSelectionRects(element, selStart, selEnd) - - -- Apply scissor for single-line editable inputs - if not element.multiline then - love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight) - end - - -- Draw selection background rectangles - love.graphics.setColor(selectionWithOpacity:toRGBA()) - for _, rect in ipairs(selectionRects) do - local rectX = contentX + rect.x - local rectY = contentY + rect.y - if not element.multiline and element._textEditor._textScrollX then - rectX = rectX - element._textEditor._textScrollX - end - love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height) - end - - -- Reset scissor - if not element.multiline then - love.graphics.setScissor() - end - end - end - - if element.textSize then - love.graphics.setFont(origFont) - end - end - - -- Draw cursor for focused editable elements even when empty - if - element._textEditor - and element._textEditor:isFocused() - and element._textEditor._cursorVisible - and (not displayText or displayText == "") - then - -- Set up font for cursor rendering - local origFont = love.graphics.getFont() - if element.textSize then - local font = - self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) - love.graphics.setFont(font) - end - - local font = love.graphics.getFont() - local textHeight = font:getHeight() - - -- Calculate text area position - local textPaddingLeft = element.padding.left - local textPaddingTop = element.padding.top - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - textPaddingLeft = scaledContentPadding.left - textPaddingTop = scaledContentPadding.top - end - - local contentX = element.x + textPaddingLeft - local contentY = element.y + textPaddingTop - - -- Draw cursor - local cursorColor = element.cursorColor or element.textColor - local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1) - local cursorWithOpacity = self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity) - love.graphics.setColor(cursorWithOpacity:toRGBA()) - love.graphics.rectangle("fill", contentX, contentY, 2, textHeight) - - if element.textSize then - love.graphics.setFont(origFont) - end - end -end - ---- Draw scrollbars (both vertical and horizontal) ----@param element table Reference to the parent Element instance ----@param x number X position ----@param y number Y position ----@param w number Width ----@param h number Height ----@param dims table Scrollbar dimensions from _calculateScrollbarDimensions -function Renderer:drawScrollbars(element, x, y, w, h, dims) - -- Try to get themed scrollbar component - local scrollbarComponent = nil - if element.scrollBarStyle or self._Theme.hasActive() then - scrollbarComponent = self._Theme.getScrollbar(element.scrollBarStyle) - end - - -- Vertical scrollbar - if dims.vertical.visible and not element.hideScrollbars.vertical then - -- Position scrollbar within content area (x, y is border-box origin) - local contentX = x + element.padding.left - local contentY = y + element.padding.top - local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding - local trackY = contentY + element.scrollbarPadding - - -- Check if we should use themed rendering - if scrollbarComponent then - -- Themed scrollbar rendering using NinePatch - local frameComponent = scrollbarComponent.frame or scrollbarComponent - local barComponent = scrollbarComponent.bar or scrollbarComponent - - -- Calculate knob offset (element overrides theme) - local knobOffsetX = 0 - local knobOffsetY = 0 - - -- Use element offset if provided, otherwise use theme offset - if element.scrollbarKnobOffset then - knobOffsetX = element.scrollbarKnobOffset.x or 0 - knobOffsetY = element.scrollbarKnobOffset.vertical or 0 - elseif barComponent and barComponent.knobOffset then - local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) - knobOffsetX = themeOffset.x - knobOffsetY = themeOffset.vertical - end - - -- Extract contentPadding top inset from frame for knob sizing. - -- Vertical scrollbar only consumes framePaddingTop; other insets are unused. - local framePaddingTop = 0 - if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then - framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0 - end - - -- Draw track (frame) if component exists - if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then - self._NinePatch.draw( - frameComponent, - frameComponent._loadedAtlas, - trackX, - trackY, - element.scrollbarWidth, - dims.vertical.trackHeight - ) - end - - -- Draw thumb (bar) if component exists - if barComponent and barComponent._loadedAtlas and barComponent.regions then - -- Adjust knob dimensions to account for frame's contentPadding - -- Vertical scrollbar: width affected by left+right, height affected by top+bottom - local knobWidth = element.scrollbarWidth - local knobHeight = dims.vertical.thumbHeight - framePaddingTop / 2 - self._NinePatch.draw( - barComponent, - barComponent._loadedAtlas, - trackX + knobOffsetX, - trackY + dims.vertical.thumbY + knobOffsetY, - knobWidth, - knobHeight - ) - end - else - -- Fallback to color-based rendering - -- Determine thumb color based on state (independent for vertical) - local thumbColor = element.scrollbarColor - if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then - -- Active state: brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) - thumbColor = self._Color.new(r, g, b, a) - elseif element._scrollbarHoveredVertical then - -- Hover state: slightly brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) - thumbColor = self._Color.new(r, g, b, a) - end - - -- Draw track - love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle( - "fill", - trackX, - trackY, - element.scrollbarWidth, - dims.vertical.trackHeight, - element.scrollbarRadius - ) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle( - "fill", - trackX, - trackY + dims.vertical.thumbY, - element.scrollbarWidth, - dims.vertical.thumbHeight, - element.scrollbarRadius - ) - end - end - - -- Horizontal scrollbar - if dims.horizontal.visible and not element.hideScrollbars.horizontal then - -- Position scrollbar within content area (x, y is border-box origin) - local contentX = x + element.padding.left - local contentY = y + element.padding.top - local trackX = contentX + element.scrollbarPadding - local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding - - -- Check if we should use themed rendering - if scrollbarComponent then - -- Themed scrollbar rendering using NinePatch - local frameComponent = scrollbarComponent.frame or scrollbarComponent - local barComponent = scrollbarComponent.bar or scrollbarComponent - - -- Calculate knob offset (element overrides theme) - local knobOffsetX = 0 - local knobOffsetY = 0 - - -- Use element offset if provided, otherwise use theme offset - if element.scrollbarKnobOffset then - knobOffsetX = element.scrollbarKnobOffset.horizontal or 0 - knobOffsetY = element.scrollbarKnobOffset.y or 0 - elseif barComponent and barComponent.knobOffset then - local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0) - knobOffsetX = themeOffset.horizontal - knobOffsetY = themeOffset.y - end - - -- Extract contentPadding from frame for knob sizing (horizontal: right inset unused). - local framePaddingLeft = 0 - local framePaddingTop = 0 - local framePaddingBottom = 0 - if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then - framePaddingLeft = frameComponent._ninePatchData.contentPadding.left or 0 - framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0 - framePaddingBottom = frameComponent._ninePatchData.contentPadding.bottom or 0 - end - - -- Draw track (frame) if component exists - if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then - self._NinePatch.draw( - frameComponent, - frameComponent._loadedAtlas, - trackX, - trackY, - dims.horizontal.trackWidth, - element.scrollbarWidth - ) - end - - -- Draw thumb (bar) if component exists - if barComponent and barComponent._loadedAtlas and barComponent.regions then - -- Adjust knob dimensions to account for frame's contentPadding - -- Horizontal scrollbar: width affected by left+right, height affected by top+bottom - local knobWidth = dims.horizontal.thumbWidth - framePaddingLeft / 2 - local knobHeight = element.scrollbarWidth - framePaddingTop - framePaddingBottom - self._NinePatch.draw( - barComponent, - barComponent._loadedAtlas, - trackX + dims.horizontal.thumbX + knobOffsetX, - trackY + knobOffsetY, - knobWidth, - knobHeight - ) - end - else - -- Fallback to color-based rendering - -- Determine thumb color based on state (independent for horizontal) - local thumbColor = element.scrollbarColor - if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then - -- Active state: brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) - thumbColor = self._Color.new(r, g, b, a) - elseif element._scrollbarHoveredHorizontal then - -- Hover state: slightly brighter - local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) - thumbColor = self._Color.new(r, g, b, a) - end - - -- Draw track - love.graphics.setColor(element.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle( - "fill", - trackX, - trackY, - dims.horizontal.trackWidth, - element.scrollbarWidth, - element.scrollbarRadius - ) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle( - "fill", - trackX + dims.horizontal.thumbX, - trackY, - dims.horizontal.thumbWidth, - element.scrollbarWidth, - element.scrollbarRadius - ) - end - end - - -- Reset color - love.graphics.setColor(1, 1, 1, 1) -end - ---- Draw visual feedback when element is pressed ----@param x number X position ----@param y number Y position ----@param borderBoxWidth number Border box width ----@param borderBoxHeight number Border box height ----@param opacity number Element opacity ----@param cornerRadius number|table Corner radius -function Renderer:drawPressedState(x, y, borderBoxWidth, borderBoxHeight, opacity, cornerRadius) - love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * (opacity or 1)) - self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, cornerRadius) -end - ---- Cleanup renderer resources -function Renderer:destroy() - self._loadedImage = nil - self._blurInstance = nil -end - -return Renderer diff --git a/libs/flexlove/modules/RoundedRect.lua b/libs/flexlove/modules/RoundedRect.lua deleted file mode 100644 index 8db7222d..00000000 --- a/libs/flexlove/modules/RoundedRect.lua +++ /dev/null @@ -1,124 +0,0 @@ -local RoundedRect = {} - ---- Generate points for a rounded rectangle ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number ----@param segments number? -- Number of segments per corner arc (default: 10) ----@return table -- Array of vertices for love.graphics.polygon -function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments) - segments = segments or 10 - local points = {} - - -- Helper to add arc points - local function addArc(cx, cy, radius, startAngle, endAngle) - if radius <= 0 then - table.insert(points, cx) - table.insert(points, cy) - return - end - - for i = 0, segments do - local angle = startAngle + (endAngle - startAngle) * (i / segments) - table.insert(points, cx + math.cos(angle) * radius) - table.insert(points, cy + math.sin(angle) * radius) - end - end - - -- Handle uniform corner radius (number) - if type(cornerRadius) == "number" then - cornerRadius = { - topLeft = cornerRadius, - topRight = cornerRadius, - bottomLeft = cornerRadius, - bottomRight = cornerRadius, - } - end - - local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2) - local r2 = math.min(cornerRadius.topRight, width / 2, height / 2) - local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2) - local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2) - - -- Top-right corner - addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0) - - -- Bottom-right corner - addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2) - - -- Bottom-left corner - addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi) - - -- Top-left corner - addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5) - - return points -end - ---- Draw a filled rounded rectangle ----@param mode string -- "fill" or "line" ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil -function RoundedRect.draw(mode, x, y, width, height, cornerRadius) - -- OPTIMIZATION: Handle nil cornerRadius (no rounding) - if not cornerRadius then - love.graphics.rectangle(mode, x, y, width, height) - return - end - - -- Handle uniform corner radius (number) - if type(cornerRadius) == "number" then - if cornerRadius <= 0 then - love.graphics.rectangle(mode, x, y, width, height) - return - end - -- Convert to table format for processing - cornerRadius = { - topLeft = cornerRadius, - topRight = cornerRadius, - bottomLeft = cornerRadius, - bottomRight = cornerRadius, - } - end - - -- Check if any corners are rounded - local hasRoundedCorners = cornerRadius.topLeft > 0 - or cornerRadius.topRight > 0 - or cornerRadius.bottomLeft > 0 - or cornerRadius.bottomRight > 0 - - if not hasRoundedCorners then - -- No rounded corners, use regular rectangle - love.graphics.rectangle(mode, x, y, width, height) - return - end - - local points = RoundedRect.getPoints(x, y, width, height, cornerRadius) - - if mode == "fill" then - love.graphics.polygon("fill", points) - else - -- For line mode, draw the outline - love.graphics.polygon("line", points) - end -end - ---- Create a stencil function for rounded rectangle clipping ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil ----@return function -function RoundedRect.stencilFunction(x, y, width, height, cornerRadius) - return function() - RoundedRect.draw("fill", x, y, width, height, cornerRadius) - end -end - -return RoundedRect diff --git a/libs/flexlove/modules/ScrollManager.lua b/libs/flexlove/modules/ScrollManager.lua deleted file mode 100644 index 3a616ee3..00000000 --- a/libs/flexlove/modules/ScrollManager.lua +++ /dev/null @@ -1,1525 +0,0 @@ ----@class ScrollManager ----@field overflow string -- "visible"|"hidden"|"auto"|"scroll" ----@field overflowX string? -- X-axis specific overflow (overrides overflow) ----@field overflowY string? -- Y-axis specific overflow (overrides overflow) ----@field scrollbarWidth number -- Width/height of scrollbar track ----@field scrollbarColor Color -- Scrollbar thumb color ----@field scrollbarTrackColor Color -- Scrollbar track background color ----@field scrollbarRadius number -- Border radius for scrollbars ----@field scrollbarPadding number -- Padding around scrollbar ----@field scrollSpeed number -- Scroll speed for wheel events (pixels per wheel unit) ----@field invertScroll boolean -- Invert mouse wheel scroll direction (default: false) ----@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars) ----@field scrollbarKnobOffset table -- {x: number, y: number, horizontal: number, vertical: number} -- Offset for scrollbar knob/handle position ----@field hideScrollbars table -- {vertical: boolean, horizontal: boolean} ----@field scrollbarPlacement string -- "reserve-space"|"overlay" -- Whether scrollbar reserves space or overlays content (default: "reserve-space") ----@field scrollbarBalance boolean -- When true, reserve space on both sides of content for visual balance (default: false) ----@field touchScrollEnabled boolean -- Enable touch scrolling ----@field momentumScrollEnabled boolean -- Enable momentum scrolling ----@field bounceEnabled boolean -- Enable bounce effects at boundaries ----@field scrollFriction number -- Friction coefficient for momentum (0.95-0.98) ----@field bounceStiffness number -- Bounce spring constant (0.1-0.3) ----@field maxOverscroll number -- Maximum overscroll distance (pixels) ----@field _overflowX boolean -- True if content overflows horizontally ----@field _overflowY boolean -- True if content overflows vertically ----@field _contentWidth number -- Total content width (including overflow) ----@field _contentHeight number -- Total content height (including overflow) ----@field _scrollX number -- Current horizontal scroll position ----@field _scrollY number -- Current vertical scroll position ----@field _targetScrollX number? -- Target scroll X for smooth scrolling ----@field _targetScrollY number? -- Target scroll Y for smooth scrolling ----@field _smoothScrollSpeed number -- Speed of smooth scroll interpolation (0-1, higher = faster) ----@field _maxScrollX number -- Maximum horizontal scroll (contentWidth - containerWidth) ----@field _maxScrollY number -- Maximum vertical scroll (contentHeight - containerHeight) ----@field _scrollbarHoveredVertical boolean -- True if mouse is over vertical scrollbar ----@field _scrollbarHoveredHorizontal boolean -- True if mouse is over horizontal scrollbar ----@field _scrollbarDragging boolean -- True if currently dragging a scrollbar ----@field _hoveredScrollbar string? -- "vertical" or "horizontal" when dragging ----@field _scrollbarDragOffset number -- DEPRECATED: Offset from thumb top when drag started (kept for compatibility) ----@field _dragStartMouseX number -- Mouse X position when drag started ----@field _dragStartMouseY number -- Mouse Y position when drag started ----@field _dragStartScrollX number -- Scroll X position when drag started ----@field _dragStartScrollY number -- Scroll Y position when drag started ----@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled this frame ----@field _touchScrolling boolean -- True if currently touch scrolling ----@field _scrollVelocityX number -- Current horizontal scroll velocity (px/s) ----@field _scrollVelocityY number -- Current vertical scroll velocity (px/s) ----@field _momentumScrolling boolean -- True if momentum scrolling is active ----@field _lastTouchTime number -- Timestamp of last touch move ----@field _lastTouchX number -- Last touch X position ----@field _lastTouchY number -- Last touch Y position ----@field _Color table ----@field _utils table ----@field _ErrorHandler table? ErrorHandler module dependency -local ScrollManager = {} -ScrollManager.__index = ScrollManager - ---- Initialize module with shared dependencies ----@param deps table Dependencies {ErrorHandler} -function ScrollManager.init(deps) - if type(deps) == "table" then - ScrollManager._ErrorHandler = deps.ErrorHandler or ScrollManager._ErrorHandler - ScrollManager._Context = deps.Context or ScrollManager._Context - ScrollManager._StateManager = deps.StateManager or ScrollManager._StateManager - end -end - ---- Create a new ScrollManager instance ----@param config table Configuration options ----@param deps table Dependencies {Color: Color module, utils: utils module} ----@return ScrollManager -function ScrollManager.new(config, deps) - local Color = deps.Color - local self = setmetatable({}, ScrollManager) - - -- Store dependencies for instance methods - self._Color = Color - self._utils = deps.utils - - -- Configuration - self.overflow = config.overflow or "hidden" - self.overflowX = config.overflowX - self.overflowY = config.overflowY - - -- Scrollbar appearance - self.scrollbarWidth = config.scrollbarWidth or 12 - self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) - self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) - self.scrollbarRadius = config.scrollbarRadius or 6 - self.scrollbarPadding = config.scrollbarPadding or 2 - self.scrollSpeed = config.scrollSpeed or 20 - self.invertScroll = config.invertScroll or false - self.scrollBarStyle = config.scrollBarStyle -- Theme scrollbar style name (nil = use default) - - -- scrollbarKnobOffset can be number or table {x, y} or {horizontal, vertical} - -- Only normalize if actually provided (nil means use theme default) - if config.scrollbarKnobOffset ~= nil then - self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(config.scrollbarKnobOffset, 0) - else - self.scrollbarKnobOffset = nil - end - - -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} - self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) - - -- Scrollbar placement: "reserve-space" (default) or "overlay" - self.scrollbarPlacement = config.scrollbarPlacement or "reserve-space" - - -- Scrollbar balance: when true, reserve space on both sides for visual balance - self.scrollbarBalance = config.scrollbarBalance or false - - -- Touch scrolling configuration - self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true - self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true - self.bounceEnabled = config.bounceEnabled ~= false -- Default true - self.scrollFriction = config.scrollFriction or 0.95 -- legacy per-frame decay; superseded by _momentumDecel - self._momentumDecel = config.momentumDecel or 2.0 -- fling decay rate (1/s), dt-based; ~iOS "normal" - self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant - self.maxOverscroll = config.maxOverscroll or 100 -- pixels - - -- Internal overflow state - self._overflowX = false - self._overflowY = false - self._contentWidth = 0 - self._contentHeight = 0 - - -- Scroll state (can be restored from config in immediate mode) - self._scrollX = config._scrollX or 0 - self._scrollY = config._scrollY or 0 - self._targetScrollX = nil - self._targetScrollY = nil - self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster); legacy, superseded by _smoothScrollRate - self._smoothScrollRate = config.smoothScrollRate or 30 -- dt-based decay constant (1/s); ~90% converged in 2.3/rate s - self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling - self._maxScrollX = 0 - self._maxScrollY = 0 - - -- Scrollbar interaction state - self._scrollbarHoveredVertical = false - self._scrollbarHoveredHorizontal = false - self._scrollbarDragging = false - self._hoveredScrollbar = nil -- "vertical" or "horizontal" - self._scrollbarDragOffset = 0 -- DEPRECATED: kept for backward compatibility - self._dragStartMouseX = 0 -- Mouse X position when drag started - self._dragStartMouseY = 0 -- Mouse Y position when drag started - self._dragStartScrollX = 0 -- Scroll X position when drag started - self._dragStartScrollY = 0 -- Scroll Y position when drag started - self._scrollbarPressHandled = false - - -- Touch scrolling state - self._touchScrolling = false - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - self._momentumScrolling = false - self._lastTouchTime = 0 - self._lastTouchX = 0 - self._lastTouchY = 0 - - return self -end - ---- Get the space reserved for scrollbars (width and height reduction) ---- This is called BEFORE layout to reduce available space for children ----@param element Element The parent Element instance ----@return number reservedWidth, number reservedHeight -function ScrollManager:getReservedSpace() - if self.scrollbarPlacement ~= "reserve-space" then - return 0, 0 - end - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - local reservedWidth = 0 - local reservedHeight = 0 - - -- Reserve space for vertical scrollbar if overflow mode requires it - if (overflowY == "scroll" or overflowY == "auto") and not self.hideScrollbars.vertical then - local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) - reservedWidth = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace - end - - -- Reserve space for horizontal scrollbar if overflow mode requires it - if (overflowX == "scroll" or overflowX == "auto") and not self.hideScrollbars.horizontal then - local scrollbarSpace = self.scrollbarWidth + (self.scrollbarPadding * 2) - reservedHeight = self.scrollbarBalance and (scrollbarSpace * 2) or scrollbarSpace - end - - return reservedWidth, reservedHeight -end - ---- Detect if content overflows container bounds ----@param element Element The parent Element instance -function ScrollManager:detectOverflow(element) - -- Reset overflow state - self._overflowX = false - self._overflowY = false - self._contentWidth = element.width - self._contentHeight = element.height - - -- Skip detection if overflow is visible (no clipping needed) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - if overflowX == "visible" and overflowY == "visible" then - return - end - - -- Calculate content bounds based on children - if #element.children == 0 then - return -- No children, no overflow - end - - local maxX, maxY = 0, 0 - - -- Content area starts after padding - local contentX = element.x + element.padding.left - local contentY = element.y + element.padding.top - - for _, child in ipairs(element.children) do - -- Skip absolutely positioned children (they don't contribute to overflow) - if not child._explicitlyAbsolute then - -- Calculate child's margin box bounds relative to content area - local childMarginRight = child.x - contentX + child:getBorderBoxWidth() + child.margin.right - local childMarginBottom = child.y - contentY + child:getBorderBoxHeight() + child.margin.bottom - - -- Track the maximum extents (we ignore negative space from margins) - maxX = math.max(maxX, childMarginRight) - maxY = math.max(maxY, childMarginBottom) - end - end - - -- Calculate content dimensions - self._contentWidth = maxX - self._contentHeight = maxY - - -- Detect overflow (compare against content area, not total element size). - -- element.width/height semantics depend on unit type: - -- px units → border-box size (padding NOT yet subtracted) - -- %, vh, vw → content size (padding already subtracted by LayoutEngine) - -- auto → content size - -- Using getBorderBoxWidth/Height() normalises both cases: border-box - padding = content. - local containerWidth = element:getBorderBoxWidth() - element.padding.left - element.padding.right - local containerHeight = element:getBorderBoxHeight() - element.padding.top - element.padding.bottom - - -- If scrollbarPlacement is "reserve-space", we need to subtract the reserved space - -- because the layout already accounted for it, but element.width/height are still full size - if self.scrollbarPlacement == "reserve-space" then - local reservedWidth, reservedHeight = self:getReservedSpace() - containerWidth = containerWidth - reservedWidth - containerHeight = containerHeight - reservedHeight - end - - self._overflowX = self._contentWidth > containerWidth - self._overflowY = self._contentHeight > containerHeight - - -- Calculate maximum scroll bounds - self._maxScrollX = math.max(0, self._contentWidth - containerWidth) - self._maxScrollY = math.max(0, self._contentHeight - containerHeight) - - -- Clamp current scroll position to new bounds - self._scrollX = self._utils.clamp(self._scrollX, 0, self._maxScrollX) - self._scrollY = self._utils.clamp(self._scrollY, 0, self._maxScrollY) -end - ---- Set scroll position with bounds clamping ----@param x number? -- X scroll position (nil to keep current) ----@param y number? -- Y scroll position (nil to keep current) -function ScrollManager:setScroll(x, y) - if x ~= nil then - self._scrollX = self._utils.clamp(x, 0, self._maxScrollX) - end - if y ~= nil then - self._scrollY = self._utils.clamp(y, 0, self._maxScrollY) - end -end - ---- Get current scroll position ----@return number scrollX, number scrollY -function ScrollManager:getScroll() - return self._scrollX, self._scrollY -end - ---- Scroll by delta amount ----@param dx number? -- X delta (nil for no change) ----@param dy number? -- Y delta (nil for no change) -function ScrollManager:scrollBy(dx, dy) - if dx then - self._scrollX = self._utils.clamp(self._scrollX + dx, 0, self._maxScrollX) - end - if dy then - self._scrollY = self._utils.clamp(self._scrollY + dy, 0, self._maxScrollY) - end -end - ---- Get maximum scroll bounds ----@return number maxScrollX, number maxScrollY -function ScrollManager:getMaxScroll() - return self._maxScrollX, self._maxScrollY -end - ---- Get scroll percentage (0-1) ----@return number percentX, number percentY -function ScrollManager:getScrollPercentage() - local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - return percentX, percentY -end - ---- Check if element has overflow ----@return boolean hasOverflowX, boolean hasOverflowY -function ScrollManager:hasOverflow() - return self._overflowX, self._overflowY -end - ---- Get content dimensions (including overflow) ----@return number contentWidth, number contentHeight -function ScrollManager:getContentSize() - return self._contentWidth, self._contentHeight -end - ---- Calculate scrollbar dimensions and positions ----@param element Element The parent Element instance ----@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} -function ScrollManager:calculateScrollbarDimensions(element) - local result = { - vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, - horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, - } - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - -- Vertical scrollbar - -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows - if overflowY == "scroll" then - -- Always show scrollbar for "scroll" mode - result.vertical.visible = true - result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) - - if self._overflowY then - -- Content overflows, calculate proper thumb size - local contentRatio = element.height / math.max(self._contentHeight, element.height) - result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight - result.vertical.thumbY = maxThumbY * scrollRatio - else - -- No overflow, thumb fills entire track - result.vertical.thumbHeight = result.vertical.trackHeight - result.vertical.thumbY = 0 - end - elseif self._overflowY and overflowY == "auto" then - -- Only show scrollbar when content actually overflows - result.vertical.visible = true - result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) - - -- Calculate thumb height based on content ratio - local contentRatio = element.height / math.max(self._contentHeight, element.height) - result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight - result.vertical.thumbY = maxThumbY * scrollRatio - end - - -- Horizontal scrollbar - -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows - if overflowX == "scroll" then - -- Always show scrollbar for "scroll" mode - result.horizontal.visible = true - result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) - - if self._overflowX then - -- Content overflows, calculate proper thumb size - local contentRatio = element.width / math.max(self._contentWidth, element.width) - result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth - result.horizontal.thumbX = maxThumbX * scrollRatio - else - -- No overflow, thumb fills entire track - result.horizontal.thumbWidth = result.horizontal.trackWidth - result.horizontal.thumbX = 0 - end - elseif self._overflowX and overflowX == "auto" then - -- Only show scrollbar when content actually overflows - result.horizontal.visible = true - result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) - - -- Calculate thumb width based on content ratio - local contentRatio = element.width / math.max(self._contentWidth, element.width) - result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth - result.horizontal.thumbX = maxThumbX * scrollRatio - end - - return result -end - ---- Get scrollbar at mouse position ----@param element Element The parent Element instance ----@param mouseX number ----@param mouseY number ----@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} -function ScrollManager:getScrollbarAtPosition(element, mouseX, mouseY) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return nil - end - - local dims = self:calculateScrollbarDimensions(element) - local x, y = element.x, element.y - local w, h = element.width, element.height - - -- Check vertical scrollbar (only if not hidden) - if dims.vertical.visible and not self.hideScrollbars.vertical then - -- Position scrollbar within content area (x, y is border-box origin) - local contentX = x + element.padding.left - local contentY = y + element.padding.top - local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding - local trackY = contentY + self.scrollbarPadding - local trackW = self.scrollbarWidth - local trackH = dims.vertical.trackHeight - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbY = trackY + dims.vertical.thumbY - local thumbH = dims.vertical.thumbHeight - if mouseY >= thumbY and mouseY <= thumbY + thumbH then - return { component = "vertical", region = "thumb" } - else - return { component = "vertical", region = "track" } - end - end - end - - -- Check horizontal scrollbar (only if not hidden) - if dims.horizontal.visible and not self.hideScrollbars.horizontal then - -- Position scrollbar within content area (x, y is border-box origin) - local contentX = x + element.padding.left - local contentY = y + element.padding.top - local trackX = contentX + self.scrollbarPadding - local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding - local trackW = dims.horizontal.trackWidth - local trackH = self.scrollbarWidth - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbX = trackX + dims.horizontal.thumbX - local thumbW = dims.horizontal.thumbWidth - if mouseX >= thumbX and mouseX <= thumbX + thumbW then - return { component = "horizontal", region = "thumb" } - else - return { component = "horizontal", region = "track" } - end - end - end - - return nil -end - ---- Handle scrollbar mouse press ----@param element Element The parent Element instance ----@param mouseX number ----@param mouseY number ----@param button number ----@return boolean -- True if event was consumed -function ScrollManager:handleMousePress(element, mouseX, mouseY, button) - if button ~= 1 then - return false - end -- Only left click - - local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) - if not scrollbar then - return false - end - - if scrollbar.region == "thumb" then - -- Start dragging thumb - store start positions for relative movement tracking - self._scrollbarDragging = true - self._hoveredScrollbar = scrollbar.component - - -- Store drag start positions for relative movement calculation - self._dragStartMouseX = mouseX - self._dragStartMouseY = mouseY - self._dragStartScrollX = self._scrollX - self._dragStartScrollY = self._scrollY - - return true -- Event consumed - elseif scrollbar.region == "track" then - self:_scrollToTrackPosition(element, mouseX, mouseY, scrollbar.component) - return true - end - - return false -end - ---- Handle scrollbar drag ----@param element Element The parent Element instance ----@param mouseX number ----@param mouseY number ----@return boolean -- True if event was consumed -function ScrollManager:handleMouseMove(element, mouseX, mouseY) - if not self._scrollbarDragging then - return false - end - - local dims = self:calculateScrollbarDimensions(element) - - if self._hoveredScrollbar == "vertical" then - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate relative mouse movement from drag start - local mouseDeltaY = mouseY - self._dragStartMouseY - - -- Convert mouse delta to scroll delta - -- scrollDelta / maxScroll = thumbDelta / (trackHeight - thumbHeight) - local scrollableTrackHeight = trackH - thumbH - local scrollDelta = scrollableTrackHeight > 0 and (mouseDeltaY / scrollableTrackHeight) * self._maxScrollY or 0 - - local newScrollY = self._dragStartScrollY + scrollDelta - newScrollY = self._utils.clamp(newScrollY, 0, self._maxScrollY) - - self:setScroll(nil, newScrollY) - return true - elseif self._hoveredScrollbar == "horizontal" then - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate relative mouse movement from drag start - local mouseDeltaX = mouseX - self._dragStartMouseX - - -- Convert mouse delta to scroll delta - local scrollableTrackWidth = trackW - thumbW - local scrollDelta = scrollableTrackWidth > 0 and (mouseDeltaX / scrollableTrackWidth) * self._maxScrollX or 0 - - -- Apply delta to starting scroll position - local newScrollX = self._dragStartScrollX + scrollDelta - newScrollX = self._utils.clamp(newScrollX, 0, self._maxScrollX) - - self:setScroll(newScrollX, nil) - return true - end - - return false -end - ---- Handle scrollbar release ----@param button number ----@return boolean -- True if event was consumed -function ScrollManager:handleMouseRelease(button) - if button ~= 1 then - return false - end - - if self._scrollbarDragging then - self._scrollbarDragging = false - return true - end - - return false -end - ---- Scroll to track click position (internal helper) ----@param element Element The parent Element instance ----@param mouseX number ----@param mouseY number ----@param component string -- "vertical" or "horizontal" -function ScrollManager:_scrollToTrackPosition(element, mouseX, mouseY, component) - local dims = self:calculateScrollbarDimensions(element) - - if component == "vertical" then - local contentY = element.y + element.padding.top - local trackY = contentY + self.scrollbarPadding - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate target thumb position (centered on click) - local targetThumbY = mouseY - trackY - (thumbH / 2) - targetThumbY = self._utils.clamp(targetThumbY, 0, trackH - thumbH) - - -- Convert to scroll position - local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 - local newScrollY = scrollRatio * self._maxScrollY - - self:setScroll(nil, newScrollY) - elseif component == "horizontal" then - local contentX = element.x + element.padding.left - local trackX = contentX + self.scrollbarPadding - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate target thumb position (centered on click) - local targetThumbX = mouseX - trackX - (thumbW / 2) - targetThumbX = self._utils.clamp(targetThumbX, 0, trackW - thumbW) - - -- Convert to scroll position - local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 - local newScrollX = scrollRatio * self._maxScrollX - - self:setScroll(newScrollX, nil) - end -end - ---- Handle mouse wheel scrolling ----@param x number -- Horizontal scroll amount ----@param y number -- Vertical scroll amount ----@return boolean -- True if scroll was handled -function ScrollManager:handleWheel(x, y) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return false - end - - -- In immediate mode, overflow might not be calculated yet, so allow scrolling based on maxScroll values - -- If _overflowY is nil/false but _maxScrollY > 0, we should still allow scrolling (from restored state) - local hasVerticalOverflow = (self._overflowY and self._maxScrollY > 0) or (self._maxScrollY and self._maxScrollY > 0) - local hasHorizontalOverflow = (self._overflowX and self._maxScrollX > 0) - or (self._maxScrollX and self._maxScrollX > 0) - - local scrolled = false - - -- Vertical scrolling - if y ~= 0 and (overflowY == "scroll" or overflowY == "auto") and hasVerticalOverflow then - local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up - if self.invertScroll then - delta = -delta -- Invert scroll direction if enabled - end - if self.smoothScrollEnabled then - -- Set target for smooth scrolling instead of instant jump - self._targetScrollY = self._utils.clamp((self._targetScrollY or self._scrollY) + delta, 0, self._maxScrollY) - else - -- Instant scrolling (default behavior) - local newScrollY = self._scrollY + delta - self:setScroll(nil, newScrollY) - end - scrolled = true - end - - -- Horizontal scrolling - if x ~= 0 and (overflowX == "scroll" or overflowX == "auto") and hasHorizontalOverflow then - local delta = -x * self.scrollSpeed - if self.invertScroll then - delta = -delta -- Invert scroll direction if enabled - end - if self.smoothScrollEnabled then - -- Set target for smooth scrolling instead of instant jump - self._targetScrollX = self._utils.clamp((self._targetScrollX or self._scrollX) + delta, 0, self._maxScrollX) - else - -- Instant scrolling (default behavior) - local newScrollX = self._scrollX + delta - self:setScroll(newScrollX, nil) - end - scrolled = true - end - - return scrolled -end - ---- Update scrollbar hover state based on mouse position ----@param element Element The parent Element instance ----@param mouseX number ----@param mouseY number -function ScrollManager:updateHoverState(element, mouseX, mouseY) - local scrollbar = self:getScrollbarAtPosition(element, mouseX, mouseY) - - if scrollbar then - if scrollbar.component == "vertical" then - self._scrollbarHoveredVertical = true - self._scrollbarHoveredHorizontal = false - elseif scrollbar.component == "horizontal" then - self._scrollbarHoveredVertical = false - self._scrollbarHoveredHorizontal = true - end - else - self._scrollbarHoveredVertical = false - self._scrollbarHoveredHorizontal = false - end -end - ---- Reset scrollbar press handled flag (call at start of frame) -function ScrollManager:resetScrollbarPressFlag() - self._scrollbarPressHandled = false -end - ---- Check if scrollbar press was handled this frame ----@return boolean -function ScrollManager:wasScrollbarPressHandled() - return self._scrollbarPressHandled -end - ---- Set scrollbar press handled flag -function ScrollManager:setScrollbarPressHandled() - self._scrollbarPressHandled = true -end - ---- Get state for immediate mode persistence ----@return table State data -function ScrollManager:getState() - return { - _scrollX = self._scrollX or 0, - _scrollY = self._scrollY or 0, - _targetScrollX = self._targetScrollX, - _targetScrollY = self._targetScrollY, - _scrollbarDragging = self._scrollbarDragging or false, - _hoveredScrollbar = self._hoveredScrollbar, - _scrollbarDragOffset = self._scrollbarDragOffset or 0, -- Deprecated but kept for compatibility - _dragStartMouseX = self._dragStartMouseX or 0, - _dragStartMouseY = self._dragStartMouseY or 0, - _dragStartScrollX = self._dragStartScrollX or 0, - _dragStartScrollY = self._dragStartScrollY or 0, - _scrollbarHoveredVertical = self._scrollbarHoveredVertical or false, - _scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal or false, - scrollBarStyle = self.scrollBarStyle, - scrollbarKnobOffset = self.scrollbarKnobOffset, - scrollbarPlacement = self.scrollbarPlacement, - scrollbarBalance = self.scrollbarBalance, - _overflowX = self._overflowX, - _overflowY = self._overflowY, - _contentWidth = self._contentWidth, - _contentHeight = self._contentHeight, - -- Touch fling state: without these, immediate-mode recreation zeroes the - -- release velocity on the next frame and momentum scrolling never runs. - _touchScrolling = self._touchScrolling or false, - _momentumScrolling = self._momentumScrolling or false, - _scrollVelocityX = self._scrollVelocityX or 0, - _scrollVelocityY = self._scrollVelocityY or 0, - _lastTouchTime = self._lastTouchTime or 0, - _lastTouchX = self._lastTouchX or 0, - _lastTouchY = self._lastTouchY or 0, - } -end - ---- Set state from immediate mode persistence ----@param state table State data -function ScrollManager:setState(state) - if not state then - return - end - - -- 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 ~= nil then - self._scrollY = state._scrollY - elseif state.scrollY ~= nil then - self._scrollY = state.scrollY - end - - if state._scrollbarDragging ~= nil then - self._scrollbarDragging = state._scrollbarDragging - elseif state.scrollbarDragging ~= nil then - self._scrollbarDragging = state.scrollbarDragging - end - - if state._hoveredScrollbar ~= nil then - self._hoveredScrollbar = state._hoveredScrollbar - elseif state.hoveredScrollbar ~= nil then - self._hoveredScrollbar = state.hoveredScrollbar - end - - if state._scrollbarDragOffset ~= nil then - self._scrollbarDragOffset = state._scrollbarDragOffset - elseif state.scrollbarDragOffset ~= nil then - self._scrollbarDragOffset = state.scrollbarDragOffset - end - - -- Restore drag start positions for relative movement tracking - if state._dragStartMouseX ~= nil then - self._dragStartMouseX = state._dragStartMouseX - end - - if state._dragStartMouseY ~= nil then - self._dragStartMouseY = state._dragStartMouseY - end - - if state._dragStartScrollX ~= nil then - self._dragStartScrollX = state._dragStartScrollX - end - - if state._dragStartScrollY ~= nil then - self._dragStartScrollY = state._dragStartScrollY - end - - if state._scrollbarHoveredVertical ~= nil then - self._scrollbarHoveredVertical = state._scrollbarHoveredVertical - end - - if state._scrollbarHoveredHorizontal ~= nil then - self._scrollbarHoveredHorizontal = state._scrollbarHoveredHorizontal - end - - if state.scrollBarStyle ~= nil then - self.scrollBarStyle = state.scrollBarStyle - end - - if state.scrollbarKnobOffset ~= nil then - self.scrollbarKnobOffset = self._utils.normalizeOffsetTable(state.scrollbarKnobOffset, 0) - end - - if state.scrollbarPlacement ~= nil then - self.scrollbarPlacement = state.scrollbarPlacement - end - - if state.scrollbarBalance ~= nil then - self.scrollbarBalance = state.scrollbarBalance - end - - if state._overflowX ~= nil then - self._overflowX = state._overflowX - end - - if state._overflowY ~= nil then - self._overflowY = state._overflowY - end - - if state._contentWidth ~= nil then - self._contentWidth = state._contentWidth - end - - if state._contentHeight ~= nil then - self._contentHeight = state._contentHeight - end - - if state._targetScrollX ~= nil then - self._targetScrollX = state._targetScrollX - end - - if state._targetScrollY ~= nil then - self._targetScrollY = state._targetScrollY - end - - -- Touch fling state (see getState): restore so momentum survives - -- immediate-mode element recreation between frames. - if state._touchScrolling ~= nil then - self._touchScrolling = state._touchScrolling - end - if state._momentumScrolling ~= nil then - self._momentumScrolling = state._momentumScrolling - end - if state._scrollVelocityX ~= nil then - self._scrollVelocityX = state._scrollVelocityX - end - if state._scrollVelocityY ~= nil then - self._scrollVelocityY = state._scrollVelocityY - end - if state._lastTouchTime ~= nil then - self._lastTouchTime = state._lastTouchTime - end - if state._lastTouchX ~= nil then - self._lastTouchX = state._lastTouchX - end - if state._lastTouchY ~= nil then - self._lastTouchY = state._lastTouchY - end -end - ---- Handle touch press for scrolling ----@param touchX number ----@param touchY number ----@return boolean -- True if touch scroll started -function ScrollManager:handleTouchPress(touchX, touchY) - if not self.touchScrollEnabled then - return false - end - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return false - end - - -- Stop momentum scrolling if active - if self._momentumScrolling then - self._momentumScrolling = false - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - end - - -- Start touch scrolling - self._touchScrolling = true - self._lastTouchX = touchX - self._lastTouchY = touchY - self._lastTouchTime = love.timer.getTime() - - return true -end - ---- Handle touch move for scrolling ----@param touchX number ----@param touchY number ----@return boolean -- True if touch scroll was handled -function ScrollManager:handleTouchMove(touchX, touchY) - if not self._touchScrolling then - return false - end - - local currentTime = love.timer.getTime() - local dt = currentTime - self._lastTouchTime - - if dt <= 0 then - return false - end - - -- Calculate delta and velocity - local dx = touchX - self._lastTouchX - local dy = touchY - self._lastTouchY - - -- Invert deltas (touch moves opposite to scroll) - dx = -dx - dy = -dy - - -- Velocity tracking (pixels per second). Not the raw last-sample d/dt: - -- touch samples jitter, and the final pre-release sample often reads near - -- zero, which is why single-sample flings feel dead compared to a native - -- list. Blend toward the instantaneous velocity with a ~50 ms time - -- constant instead, like OS velocity trackers that average recent samples. - local instX, instY = dx / dt, dy / dt - local blend = math.min(1, dt / 0.05) - self._scrollVelocityX = self._scrollVelocityX + (instX - self._scrollVelocityX) * blend - self._scrollVelocityY = self._scrollVelocityY + (instY - self._scrollVelocityY) * blend - - -- Apply scroll with bounce if enabled - if self.bounceEnabled then - -- Allow overscroll - local newScrollX = self._scrollX + dx - local newScrollY = self._scrollY + dy - - -- Clamp to max overscroll limits - local minScrollX = -self.maxOverscroll - local maxScrollX = self._maxScrollX + self.maxOverscroll - local minScrollY = -self.maxOverscroll - local maxScrollY = self._maxScrollY + self.maxOverscroll - - newScrollX = self._utils.clamp(newScrollX, minScrollX, maxScrollX) - newScrollY = self._utils.clamp(newScrollY, minScrollY, maxScrollY) - - self._scrollX = newScrollX - self._scrollY = newScrollY - else - -- Normal clamped scrolling - self:scrollBy(dx, dy) - end - - -- Update last touch state - self._lastTouchX = touchX - self._lastTouchY = touchY - self._lastTouchTime = currentTime - - return true -end - ---- Handle touch release for scrolling ----@return boolean -- True if touch scroll was active -function ScrollManager:handleTouchRelease() - if not self._touchScrolling then - return false - end - - self._touchScrolling = false - - -- A finger that pauses and then lifts is a stop, not a fling: without - -- this, the smoothed velocity from the earlier drag still launches the - -- list after a deliberate hold (native lists kill the tracker the same - -- way). - if love.timer.getTime() - (self._lastTouchTime or 0) > 0.1 then - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - end - - -- Start momentum scrolling if enabled and velocity is significant - if self.momentumScrollEnabled then - local velocityThreshold = 50 -- pixels per second - local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) - - if totalVelocity > velocityThreshold then - self._momentumScrolling = true - else - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - end - else - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - end - - return true -end - ---- Update momentum scrolling (call every frame with dt) ----@param dt number Delta time in seconds -function ScrollManager:update(dt) - -- Smooth scroll interpolation. The blend factor is derived from dt - -- (exponential approach) rather than a fixed per-frame fraction, so the - -- animation converges in the same wall-clock time regardless of frame - -- rate; a fixed fraction made scrolling visibly slower whenever frame - -- time rose. _smoothScrollRate is the decay constant in 1/seconds: - -- ~90% of the remaining distance is covered in 2.3/rate seconds. - -- dt can legitimately be 0 here: in immediate mode beginFrame resets the - -- accumulated dt BEFORE endFrame updates the elements, so the launcher's - -- update order always hands this 0. Treat that (and nil) as one nominal - -- 60 Hz step so the interpolation still advances one frame's worth. - local step = (dt and dt > 0) and dt or (1 / 60) - if self._targetScrollX or self._targetScrollY then - local alpha = 1 - math.exp(-(self._smoothScrollRate or 30) * step) - if self._targetScrollY then - local diff = self._targetScrollY - self._scrollY - if math.abs(diff) > 0.5 then - self._scrollY = self._scrollY + diff * alpha - else - self._scrollY = self._targetScrollY - self._targetScrollY = nil - end - end - - if self._targetScrollX then - local diff = self._targetScrollX - self._scrollX - if math.abs(diff) > 0.5 then - self._scrollX = self._scrollX + diff * alpha - else - self._scrollX = self._targetScrollX - self._targetScrollX = nil - end - end - end - - if not self._momentumScrolling then - -- Handle bounce back if overscrolled - if self.bounceEnabled then - self:_updateBounce(dt) - end - return - end - - -- Apply velocity to scroll position (step, not dt: dt is 0 in the - -- immediate-mode endFrame path, which left touch flings completely dead) - local dx = self._scrollVelocityX * step - local dy = self._scrollVelocityY * step - - if self.bounceEnabled then - -- Allow overscroll during momentum, but never past maxOverscroll: an - -- unclamped fling carried a list hundreds of pixels past its edge - -- (rows vanished into a blank card until the spring crawled back). - self._scrollX = self._utils.clamp(self._scrollX + dx, - -self.maxOverscroll, self._maxScrollX + self.maxOverscroll) - self._scrollY = self._utils.clamp(self._scrollY + dy, - -self.maxOverscroll, self._maxScrollY + self.maxOverscroll) - else - self:scrollBy(dx, dy) - end - - -- Frame-rate-independent exponential decay tuned to native list feel: - -- iOS "normal" deceleration is ~0.998 per millisecond, i.e. exp(-2.0 t). - -- The old per-frame 0.95 factor killed a fling in a fraction of a second - -- (and faster the higher the frame rate). Total fling travel is - -- velocity / rate. - local decay = math.exp(-(self._momentumDecel or 2.0) * step) - -- Past the content edge, kill the fling an order of magnitude faster so - -- the rubber band absorbs it instead of stretching to the clamp and - -- sitting there (native lists do the same). - local overX = self._scrollX < 0 or self._scrollX > self._maxScrollX - local overY = self._scrollY < 0 or self._scrollY > self._maxScrollY - if overX or overY then - decay = decay * math.exp(-20 * step) - end - self._scrollVelocityX = self._scrollVelocityX * decay - self._scrollVelocityY = self._scrollVelocityY * decay - - -- Stop momentum when velocity is very low - local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) - if totalVelocity < 5 then - self._momentumScrolling = false - self._scrollVelocityX = 0 - self._scrollVelocityY = 0 - end - - -- Handle bounce back if overscrolled - if self.bounceEnabled then - self:_updateBounce(dt) - end -end - ---- Update bounce effect when overscrolled (internal) ----@param dt number Delta time in seconds -function ScrollManager:_updateBounce() - local bounced = false - - -- Bounce back horizontal overscroll - if self._scrollX < 0 then - local springForce = -self._scrollX * self.bounceStiffness - self._scrollX = self._scrollX + springForce - if math.abs(self._scrollX) < 0.5 then - self._scrollX = 0 - end - bounced = true - elseif self._scrollX > self._maxScrollX then - local overflow = self._scrollX - self._maxScrollX - local springForce = -overflow * self.bounceStiffness - self._scrollX = self._scrollX + springForce - if math.abs(overflow) < 0.5 then - self._scrollX = self._maxScrollX - end - bounced = true - end - - -- Bounce back vertical overscroll - if self._scrollY < 0 then - local springForce = -self._scrollY * self.bounceStiffness - self._scrollY = self._scrollY + springForce - if math.abs(self._scrollY) < 0.5 then - self._scrollY = 0 - end - bounced = true - elseif self._scrollY > self._maxScrollY then - local overflow = self._scrollY - self._maxScrollY - local springForce = -overflow * self.bounceStiffness - self._scrollY = self._scrollY + springForce - if math.abs(overflow) < 0.5 then - self._scrollY = self._maxScrollY - end - bounced = true - end - - -- Stop momentum if bouncing - if bounced and self._momentumScrolling then - -- Reduce velocity during bounce - self._scrollVelocityX = self._scrollVelocityX * 0.9 - self._scrollVelocityY = self._scrollVelocityY * 0.9 - end -end - ---- Check if currently touch scrolling ----@return boolean -function ScrollManager:isTouchScrolling() - return self._touchScrolling -end - ---- Check if currently momentum scrolling ----@return boolean -function ScrollManager:isMomentumScrolling() - return self._momentumScrolling -end - -------------------------------------------------------------------------------- --- Element-facing delegates --- --- These wrappers bind the Element class's scroll API directly onto the --- ScrollManager instance methods. Each takes the Element as its first argument --- (the role `self` played when these methods lived on Element), performs the --- nil-safety guard, invokes the owning ScrollManager instance, and syncs state --- back onto the element for backward-compatible readers (Renderer, FlexLove, --- Context hit-testing read these fields from Element). --- --- Element binds them via direct assignment in Element.init, e.g. --- Element.scrollToTop = Element._ScrollManager.scrollToTop --- so Element retains only 1-line delegates and owns no scroll logic. -------------------------------------------------------------------------------- - -local _EMPTY_SCROLLBAR_DIMS = { - vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, - horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, -} - ---- Sync internal scroll state onto the element for backward-compatible readers. ----@param element table Element instance whose _scrollManager holds the state -function ScrollManager.syncToElement(element) - local sm = element._scrollManager - if not sm then - return - end - element._overflowX = sm._overflowX - element._overflowY = sm._overflowY - element._contentWidth = sm._contentWidth - element._contentHeight = sm._contentHeight - element._scrollX = sm._scrollX - element._scrollY = sm._scrollY - element._maxScrollX = sm._maxScrollX - element._maxScrollY = sm._maxScrollY - element._scrollbarHoveredVertical = sm._scrollbarHoveredVertical - element._scrollbarHoveredHorizontal = sm._scrollbarHoveredHorizontal - element._scrollbarDragging = sm._scrollbarDragging - element._hoveredScrollbar = sm._hoveredScrollbar - element._scrollbarDragOffset = sm._scrollbarDragOffset -end - ---- Backward-compatible alias retained by Element internals (update hover/drag). -ScrollManager.syncScrollManagerState = ScrollManager.syncToElement - ---- Detect overflow and sync state onto element. ----@param element table Element instance -function ScrollManager._detectOverflow(element) - local sm = element._scrollManager - if not sm then - return - end - sm:detectOverflow(element) - ScrollManager.syncToElement(element) -end - ---- Set scroll position (element-facing). Nil args keep the current axis. ----@param element table Element instance ----@param x number? X scroll position ----@param y number? Y scroll position -function ScrollManager.setScrollPosition(element, x, y) - local sm = element._scrollManager - if not sm then - return - end - sm:setScroll(x, y) - ScrollManager.syncToElement(element) -end - ---- Calculate scrollbar dimensions (element-facing). ----@param element table Element instance ----@return table dims {vertical, horizontal} -function ScrollManager._calculateScrollbarDimensions(element) - local sm = element._scrollManager - if not sm then - return _EMPTY_SCROLLBAR_DIMS - end - return sm:calculateScrollbarDimensions(element) -end - ---- Get scrollbar at mouse position (element-facing). ----@param element table Element instance ----@param mouseX number ----@param mouseY number ----@return table|nil {component, region} -function ScrollManager._getScrollbarAtPosition(element, mouseX, mouseY) - local sm = element._scrollManager - if not sm then - return nil - end - return sm:getScrollbarAtPosition(element, mouseX, mouseY) -end - ---- Handle scrollbar mouse press (element-facing). ----@param element table Element instance ----@param mouseX number ----@param mouseY number ----@param button number ----@return boolean consumed -function ScrollManager._handleScrollbarPress(element, mouseX, mouseY, button) - local sm = element._scrollManager - if not sm then - return false - end - local consumed = sm:handleMousePress(element, mouseX, mouseY, button) - ScrollManager.syncToElement(element) - return consumed -end - ---- Handle scrollbar drag (element-facing). ----@param element table Element instance ----@param mouseX number ----@param mouseY number ----@return boolean consumed -function ScrollManager._handleScrollbarDrag(element, mouseX, mouseY) - local sm = element._scrollManager - if not sm then - return false - end - local consumed = sm:handleMouseMove(element, mouseX, mouseY) - ScrollManager.syncToElement(element) - return consumed -end - ---- Handle scrollbar release (element-facing). ----@param element table Element instance ----@param button number ----@return boolean consumed -function ScrollManager._handleScrollbarRelease(element, button) - local sm = element._scrollManager - if not sm then - return false - end - local consumed = sm:handleMouseRelease(button) - ScrollManager.syncToElement(element) - return consumed -end - ---- Handle mouse wheel scrolling (element-facing). ----@param element table Element instance ----@param x number Horizontal scroll amount ----@param y number Vertical scroll amount ----@return boolean consumed -function ScrollManager._handleWheelScroll(element, x, y) - local sm = element._scrollManager - if not sm then - return false - end - local consumed = sm:handleWheel(x, y) - ScrollManager.syncToElement(element) - return consumed -end - ---- Get current scroll position (element-facing). ----@param element table Element instance ----@return number scrollX, number scrollY -function ScrollManager.getScrollPosition(element) - local sm = element._scrollManager - if not sm then - return 0, 0 - end - return sm:getScroll() -end - --- The following getters share names with ScrollManager *instance* methods, --- so the element-facing wrappers use distinct `element`-prefixed names to --- avoid shadowing the instance API (tests call sm:getMaxScroll() etc.). - ---- Get maximum scroll bounds (element-facing). ----@param element table Element instance ----@return number maxScrollX, number maxScrollY -function ScrollManager.elementGetMaxScroll(element) - local sm = element._scrollManager - if not sm then - return 0, 0 - end - return sm:getMaxScroll() -end - ---- Get scroll percentage 0-1 (element-facing). ----@param element table Element instance ----@return number percentX, number percentY -function ScrollManager.elementGetScrollPercentage(element) - local sm = element._scrollManager - if not sm then - return 0, 0 - end - return sm:getScrollPercentage() -end - ---- Check if element has overflow (element-facing). ----@param element table Element instance ----@return boolean hasOverflowX, boolean hasOverflowY -function ScrollManager.elementHasOverflow(element) - local sm = element._scrollManager - if not sm then - return false, false - end - return sm:hasOverflow() -end - ---- Get content dimensions (element-facing). ----@param element table Element instance ----@return number contentWidth, number contentHeight -function ScrollManager.elementGetContentSize(element) - local sm = element._scrollManager - if not sm then - return 0, 0 - end - return sm:getContentSize() -end - ---- Scroll by relative delta (element-facing). --- In immediate mode, per-axis deltas whose scroll bound is still 0 are deferred --- until layout calculates the bound (delegates to Element:_deferMethod). ----@param element table Element instance ----@param dx number? X delta ----@param dy number? Y delta -function ScrollManager.elementScrollBy(element, dx, dy) - local sm = element._scrollManager - if not sm then - return - end - local maxScrollX, maxScrollY = sm:getMaxScroll() - if dx ~= nil and maxScrollX == 0 then - element:_deferMethod("scrollBy", dx, nil) - dx = nil - end - if dy ~= nil and maxScrollY == 0 then - element:_deferMethod("scrollBy", nil, dy) - dy = nil - end - if dx ~= nil or dy ~= nil then - sm:scrollBy(dx, dy) - ScrollManager.syncToElement(element) - end -end - ---- Jump to the top of scrollable content. ----@param element table Element instance -function ScrollManager.scrollToTop(element) - element:setScrollPosition(nil, 0) -end - ---- Jump to the bottom of scrollable content. --- Defers until layout has calculated the vertical scroll bound. ----@param element table Element instance -function ScrollManager.scrollToBottom(element) - local sm = element._scrollManager - if not sm then - return - end - local _, maxScrollY = sm:getMaxScroll() - if maxScrollY > 0 then - element:setScrollPosition(nil, maxScrollY) - else - element:_deferMethod("scrollToBottom") - end -end - ---- Jump to the leftmost position of scrollable content. ----@param element table Element instance -function ScrollManager.scrollToLeft(element) - element:setScrollPosition(0, nil) -end - ---- Jump to the rightmost position of scrollable content. --- Defers until layout has calculated the horizontal scroll bound. ----@param element table Element instance -function ScrollManager.scrollToRight(element) - local sm = element._scrollManager - if not sm then - return - end - local maxScrollX, _ = sm:getMaxScroll() - if maxScrollX > 0 then - element:setScrollPosition(maxScrollX, nil) - else - element:_deferMethod("scrollToRight") - end -end - ---- Restore scrollbar state from StateManager in immediate mode. ----@param element table Element instance -function ScrollManager.restoreImmediateState(element) - -- Mode-aware guard: only immediate-mode frames keep state in StateManager; - -- in retained mode the element (and its ScrollManager) persist between - -- frames, so there is nothing to restore. Routed through StateManager so no - -- raw mode check lives here (behavior-mode-unification task 11). - if not element._stateId or not ScrollManager._StateManager.isImmediateMode() then - return - end - local state = ScrollManager._StateManager.getState(element._stateId) - if not state or not state.scrollManager then - return - end - local sm_state = state.scrollManager - element._scrollbarHoveredVertical = sm_state._scrollbarHoveredVertical or false - element._scrollbarHoveredHorizontal = sm_state._scrollbarHoveredHorizontal or false - element._scrollbarDragging = sm_state._scrollbarDragging or false - element._hoveredScrollbar = sm_state._hoveredScrollbar - element._scrollbarDragOffset = sm_state._scrollbarDragOffset or 0 - - local sm = element._scrollManager - if sm then - sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical - sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal - sm._scrollbarDragging = element._scrollbarDragging - sm._hoveredScrollbar = element._hoveredScrollbar - sm._scrollbarDragOffset = element._scrollbarDragOffset - sm._dragStartMouseX = sm_state._dragStartMouseX or 0 - sm._dragStartMouseY = sm_state._dragStartMouseY or 0 - sm._dragStartScrollX = sm_state._dragStartScrollX or 0 - sm._dragStartScrollY = sm_state._dragStartScrollY or 0 - end -end - ---- Update hover, drag, and press interaction for scrollbars during Element:update. ----@param element table Element instance ----@param mx number Mouse X ----@param my number Mouse Y -function ScrollManager.updateInteraction(element, mx, my) - local sm = element._scrollManager - if sm then - sm:updateHoverState(element, mx, my) - ScrollManager.syncToElement(element) - end - - if element._scrollbarDragging and love.mouse.isDown(1) then - ScrollManager._handleScrollbarDrag(element, mx, my) - elseif element._scrollbarDragging then - if sm then - sm:handleMouseRelease(1) - ScrollManager.syncToElement(element) - end - if element._stateId and ScrollManager._StateManager.isImmediateMode() then - ScrollManager._StateManager.updateState(element._stateId, { - scrollbarDragging = false, - }) - end - end - - -- Handle scrollbar press for elements with scrollable overflow - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - local hasScrollableOverflow = ( - overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - ) - - if hasScrollableOverflow and not element._scrollbarDragging then - if love.mouse.isDown(1) and not element._scrollbarPressHandled then - local scrollbarPressed = ScrollManager._handleScrollbarPress(element, mx, my, 1) - if scrollbarPressed then - element._scrollbarPressHandled = true - end - elseif not love.mouse.isDown(1) then - element._scrollbarPressHandled = false - end - end -end - -return ScrollManager diff --git a/libs/flexlove/modules/Select.lua b/libs/flexlove/modules/Select.lua deleted file mode 100644 index 21de8ecd..00000000 --- a/libs/flexlove/modules/Select.lua +++ /dev/null @@ -1,719 +0,0 @@ ----@class Select -local Select = {} - ----Initialize Select module with required dependencies ----@param deps table -function Select.init(deps) - Select._ErrorHandler = deps.ErrorHandler - Select._Context = deps.Context - Select._StateManager = deps.StateManager - Select._utils = deps.utils - Select._Element = deps.Element -end - ----Initialize selectParent state on an element ----@param element Element ----@param selectParentConfig table -function Select.initSelectParent(element, selectParentConfig) - element._selectState = { - value = selectParentConfig.value, - open = selectParentConfig.open or false, - placeholder = selectParentConfig.placeholder, - selectFrame = nil, - selectAnchor = nil, - onChange = selectParentConfig.onChange, - options = {}, - optionLookup = {}, - expectedFrameParent = nil, - frameAdopted = false, - } - - -- Restore select state from StateManager. Mode-aware via - -- Context.isImmediateMode (behavior-mode-unification task 11). - if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then - local state = Select._StateManager.getState(element._stateId) - if state and state._selectOpen ~= nil then - element._selectState.open = state._selectOpen - end - if state and state._selectValue ~= nil then - element._selectState.value = state._selectValue - if element.selectParent then - element.selectParent.value = state._selectValue - end - end - if state and state._selectSelectedLabel ~= nil then - element._selectState.selectedLabel = state._selectSelectedLabel - end - end -end - ----Initialize selectOption on an element ----@param element Element ----@param selectOptionConfig table -function Select.initSelectOption(element, selectOptionConfig) - element.selectOption = { - value = selectOptionConfig.value, - label = selectOptionConfig.label or element.text, - disabled = selectOptionConfig.disabled or false, - } -end - ----@param selectParent Element -function Select.rebuildOptionLookup(selectParent) - if not selectParent or not selectParent._selectState then - return - end - - selectParent._selectState.optionLookup = {} - for _, optionElement in ipairs(selectParent._selectState.options) do - if optionElement and optionElement.selectOption then - selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement - end - end -end - ----@param selectParent Element -function Select.syncOptionStates(selectParent) - if not selectParent or not selectParent._selectState then - return - end - - local selectedOption = nil - local selectedLabel = selectParent._selectState.selectedLabel - - for _, optionElement in ipairs(selectParent._selectState.options) do - local isSelected = optionElement.selectOption - and optionElement.selectOption.value == selectParent._selectState.value - optionElement._selectSelected = isSelected - optionElement.ariaChecked = isSelected - - if isSelected then - selectedOption = optionElement - selectedLabel = optionElement.selectOption.label or optionElement.text - end - end - - selectParent._selectState.selectedOption = selectedOption - selectParent._selectState.selectedLabel = selectedLabel -end - ----@param element Element -function Select.resetOptions(element) - if not element._selectState then - return - end - - element._selectState.options = {} - element._selectState.optionLookup = {} - element._selectState.selectedOption = nil -end - ----@param frame any ----@return boolean -function Select.isValidSelectFrame(frame) - local Element = Select._Element - return type(frame) == "table" and getmetatable(frame) == Element -end - ----@param element Element ----@param code string ----@param details table? -function Select.warnSelectFrame(element, code, details) - Select._ErrorHandler:warn("Element", code, details or { element = element.id }) -end - ----@param element Element ----@param frame Element -function Select.trackManagedFrame(element, frame) - element._selectState.selectFrame = frame - local expectedParent = element._selectState.selectAnchor or element - element._selectState.expectedFrameParent = expectedParent - element._selectState.frameAdopted = frame.parent == expectedParent - if frame._managedSelectBaseOpacity == nil then - frame._managedSelectBaseOpacity = frame.opacity - end - if frame._managedSelectBaseVisibility == nil then - frame._managedSelectBaseVisibility = frame.visibility or "visible" - end - if frame._managedSelectBaseDisabled == nil then - frame._managedSelectBaseDisabled = frame.disabled or false - end - frame._managedSelectOwner = element - frame._managedSelectFrame = true -end - ----@param element Element ----@return Element -function Select.getOrCreateManagedAnchor(element) - if element._selectState.selectAnchor then - return element._selectState.selectAnchor - end - - local Element = Select._Element - local anchor = Element.new({ - id = string.format("%s__select_anchor", element.id or "select"), - parent = element, - positioning = Select._utils.enums.Positioning.ABSOLUTE, - left = 0, - top = element:getBorderBoxHeight(), - width = element:getBorderBoxWidth(), - opacity = 1, - visibility = "hidden", - disabled = true, - }) - - anchor._managedSelectAnchor = true - anchor._managedSelectOwner = element - element._selectState.selectAnchor = anchor - return anchor -end - ----@param element Element ----@param frame Element -function Select.applyManagedFrameLayout(element, frame) - local anchor = Select.getOrCreateManagedAnchor(element) - local triggerBorderBoxWidth = element:getBorderBoxWidth() - anchor.left = 0 - anchor.top = element:getBorderBoxHeight() - anchor.width = triggerBorderBoxWidth - anchor.units.left = { value = 0, unit = "px" } - anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" } - anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" } - frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth - - frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE - frame._explicitlyAbsolute = false - frame.left = nil - frame.top = nil - frame.right = nil - frame.bottom = nil - - if frame.parent ~= anchor then - frame:setParent(anchor) - end - - if frame.autosizing and frame.autosizing.width then - local contentWidth = frame:calculateAutoWidth() - frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right - frame.width = contentWidth - end - - if frame.parent == anchor then - anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth()) - anchor.units.width = { value = anchor.width, unit = "px" } - end - - element._selectState.expectedFrameParent = anchor - element._selectState.frameAdopted = frame.parent == anchor -end - ----@param element Element ----@param frame Element -function Select.adoptSelectFrame(element, frame) - if not element._selectState then - return - end - - if not Select.isValidSelectFrame(frame) then - Select.warnSelectFrame(element, "ELEM_007", { - element = element.id, - property = "selectParent.selectFrame", - got = type(frame), - }) - return - end - - if frame == element then - Select.warnSelectFrame(element, "ELEM_007", { - element = element.id, - property = "selectParent.selectFrame", - reason = "select cannot use itself as its managed frame", - }) - return - end - - local anchor = Select.getOrCreateManagedAnchor(element) - - if frame.parent and frame.parent ~= element and frame.parent ~= anchor then - Select.warnSelectFrame(element, "ELEM_008", { - element = element.id, - frame = frame.id, - parent = frame.parent.id, - }) - end - - Select.trackManagedFrame(element, frame) - Select.applyManagedFrameLayout(element, frame) - Select.syncManagedFrameVisibility(element) - - -- Layout is deferred to endFrame in immediate mode. shouldLayout() - -- encapsulates the mode check (behavior-mode-unification task 11). - if Select._StateManager.shouldLayout() then - anchor:layoutChildren() - element:layoutChildren() - end - - local pendingOptions = {} - for _, child in ipairs(element.children) do - if child ~= frame and child.selectOption then - table.insert(pendingOptions, child) - end - end - - for _, option in ipairs(pendingOptions) do - Select.attachOptionToManagedFrame(option) - end -end - ----@param element Element -function Select.ensureFrameState(element) - if not element._selectState or not element._selectState.selectFrame then - return - end - - local frame = element._selectState.selectFrame - local anchor = element._selectState.selectAnchor - if anchor then - local triggerBorderBoxWidth = element:getBorderBoxWidth() - anchor.left = 0 - anchor.top = element:getBorderBoxHeight() - anchor.width = triggerBorderBoxWidth - anchor.units.left = { value = 0, unit = "px" } - anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" } - anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" } - frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth - if frame.autosizing and frame.autosizing.width then - local contentWidth = frame:calculateAutoWidth() - frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right - frame.width = contentWidth - end - if frame.parent == anchor then - anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth()) - anchor.units.width = { value = anchor.width, unit = "px" } - end - if frame.parent == anchor then - anchor:layoutChildren() - end - elseif frame.parent == element then - Select.applyManagedFrameLayout(element, frame) - end - - local expectedParent = anchor or element._selectState.expectedFrameParent - if frame.parent ~= expectedParent then - Select.warnSelectFrame(element, "ELEM_009", { - element = element.id, - frame = frame.id, - expectedParent = expectedParent and expectedParent.id or nil, - actualParent = frame.parent and frame.parent.id or nil, - }) - element._selectState.expectedFrameParent = frame.parent - element._selectState.frameAdopted = frame.parent == expectedParent - end -end - ----@param element Element -function Select.syncManagedFrameVisibility(element) - if not element._selectState or not element._selectState.selectFrame then - return - end - - local frame = element._selectState.selectFrame - local anchor = element._selectState.selectAnchor - local isOpen = element._selectState.open == true - frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden" - frame.opacity = frame._managedSelectBaseOpacity or 1 - if isOpen then - frame.disabled = frame._managedSelectBaseDisabled == true - else - frame.disabled = true - end - if anchor then - anchor.visibility = isOpen and "visible" or "hidden" - anchor.opacity = 1 - anchor.disabled = not isOpen - end -end - ----@param element Element ----@return Element? -function Select.findOwningSelectParent(element) - if element._selectParentHint and element._selectParentHint._selectState then - return element._selectParentHint - end - - local current = element.parent - while current do - if current._selectState then - return current - end - current = current.parent - end - - return nil -end - ----@param element Element -function Select.registerWithSelectParent(element) - if not element.selectOption then - return - end - - local selectParent = Select.findOwningSelectParent(element) - if not selectParent then - return - end - - element._selectParentElement = selectParent - - for _, optionElement in ipairs(selectParent._selectState.options) do - if optionElement == element then - return - end - end - - table.insert(selectParent._selectState.options, element) - Select.rebuildOptionLookup(selectParent) - Select.syncOptionStates(selectParent) -end - ----@param element Element -function Select.attachOptionToManagedFrame(element) - if not element.selectOption then - return - end - - local selectParent = Select.findOwningSelectParent(element) - if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then - return - end - - local selectFrame = selectParent._selectState.selectFrame - if element.parent ~= selectFrame then - element._selectParentHint = selectParent - - if - element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE - and element._managedSelectOptionUsesFrameLayout == nil - then - element._managedSelectOptionUsesFrameLayout = true - element.positioning = Select._utils.enums.Positioning.RELATIVE - element._originalPositioning = nil - element._explicitlyAbsolute = false - element.left = nil - element.top = nil - element.right = nil - element.bottom = nil - end - - element:setParent(selectFrame) - -- Ensure frame geometry eagerly only in retained mode; deferred to the - -- per-frame update in immediate mode (behavior-mode-unification task 11). - if Select._StateManager.shouldLayout() then - Select.ensureFrameState(selectParent) - end - end -end - ----@param element Element -function Select.unregisterFromSelectParent(element) - if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then - element._selectParentElement = nil - return - end - - local selectParent = element._selectParentElement - for index, optionElement in ipairs(selectParent._selectState.options) do - if optionElement == element then - table.remove(selectParent._selectState.options, index) - break - end - end - - Select.rebuildOptionLookup(selectParent) - Select.syncOptionStates(selectParent) - element._selectParentElement = nil -end - ----@param element Element -function Select.saveStateToStateManager(element) - if not element._selectState then - return - end - if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then - Select._StateManager.updateState(element._stateId, { - _selectOpen = element._selectState.open, - _selectValue = element._selectState.value, - _selectSelectedLabel = element._selectState.selectedLabel, - }) - end -end - ----@param element Element -function Select.openSelect(element) - if not element._selectState then - return - end - - Select.ensureFrameState(element) - element._selectState.open = true - element.ariaExpanded = true - if element.selectParent then - element.selectParent.open = true - end - Select.syncManagedFrameVisibility(element) - Select.saveStateToStateManager(element) -end - ----@param element Element -function Select.closeSelect(element) - if not element._selectState then - return - end - - Select.ensureFrameState(element) - element._selectState.open = false - element.ariaExpanded = false - if element.selectParent then - element.selectParent.open = false - end - Select.syncManagedFrameVisibility(element) - Select.saveStateToStateManager(element) -end - ----@param element Element -function Select.toggleSelect(element) - if not element._selectState then - return - end - - if element.disabled then - return - end - - if element._selectState.open then - Select.closeSelect(element) - else - Select.openSelect(element) - end - - if element.onEvent then - element.onEvent(element, { type = "selecttoggle", open = element._selectState.open }) - end -end - ----@param element Element ----@return boolean -function Select.isSelectOpen(element) - return element._selectState ~= nil and element._selectState.open == true -end - ----@param element Element ----@return any -function Select.getSelectValue(element) - if not element._selectState then - return nil - end - return element._selectState.value -end - ----@param element Element ----@return string? -function Select.getSelectLabel(element) - if not element._selectState then - return nil - end - - local selectedOption = element._selectState.selectedOption - or element._selectState.optionLookup[element._selectState.value] - if selectedOption and selectedOption.selectOption then - return selectedOption.selectOption.label or selectedOption.text - end - - return element._selectState.selectedLabel or element._selectState.placeholder -end - ----@param element Element ----@return boolean -function Select.isSelectedOption(element) - if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then - return false - end - return element._selectParentElement._selectState.value == element.selectOption.value -end - ----@param element Element ----@param value any ----@param optionElement Element? -function Select.setSelectValue(element, value, optionElement) - if not element._selectState then - return - end - - if element.disabled then - return - end - - local didChange = element._selectState.value ~= value - element._selectState.value = value - if element.selectParent then - element.selectParent.value = value - end - - if optionElement and optionElement.selectOption then - element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text - end - - Select.syncOptionStates(element) - Select.closeSelect(element) - Select.saveStateToStateManager(element) - - if element.onEvent then - element.onEvent(element, { type = "selectchange", value = value, option = optionElement }) - end - - if didChange and element._selectState.onChange then - element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil) - end -end - ----@param element Element -function Select.handleRelease(element) - if element.disabled then - return - end - - if element.selectOption then - local selectParent = element._selectParentElement or Select.findOwningSelectParent(element) - if not selectParent then - return - end - - if element.selectOption.disabled then - Select.closeSelect(selectParent) - return - end - - Select.setSelectValue(selectParent, element.selectOption.value, element) - return - end - - if element._selectState then - Select.toggleSelect(element) - end -end - ----Save select state for state persistence (called from Element:saveState) ----@param element Element ----@return table? -function Select.saveState(element) - if not element._selectState then - return nil - end - return { - value = element._selectState.value, - open = element._selectState.open, - selectedLabel = element._selectState.selectedLabel, - } -end - ----Restore select state (called from Element:restoreState) ----@param element Element ----@param state table -function Select.restoreState(element, state) - if not element._selectState or not state then - return - end - element._selectState.value = state.value - element._selectState.open = state.open or false - element._selectState.selectedLabel = state.selectedLabel - if element.selectParent then - element.selectParent.value = state.value - element.selectParent.open = state.open or false - end - element.ariaExpanded = element._selectState.open - Select.syncOptionStates(element) -end - ----Clean up select-related resources (called from Element:destroy) ----@param element Element -function Select.cleanupDestroy(element) - if element._selectState then - local frame = element._selectState.selectFrame - local anchor = element._selectState.selectAnchor - if frame then - frame._managedSelectOwner = nil - frame._managedSelectFrame = nil - frame._managedSelectBaseOpacity = nil - frame._managedSelectBaseVisibility = nil - frame._managedSelectBaseDisabled = nil - end - if anchor then - anchor._managedSelectOwner = nil - anchor._managedSelectAnchor = nil - end - element._selectState = nil - end - if element._managedSelectFrame and element._managedSelectOwner then - if element._managedSelectOwner._selectState then - element._managedSelectOwner._selectState.selectFrame = nil - element._managedSelectOwner._selectState.expectedFrameParent = nil - element._managedSelectOwner._selectState.frameAdopted = false - end - element._managedSelectOwner = nil - element._managedSelectFrame = nil - element._managedSelectBaseOpacity = nil - element._managedSelectBaseVisibility = nil - element._managedSelectBaseDisabled = nil - end - if element._managedSelectAnchor and element._managedSelectOwner then - if element._managedSelectOwner._selectState then - element._managedSelectOwner._selectState.selectAnchor = nil - end - element._managedSelectOwner = nil - element._managedSelectAnchor = nil - end - if element.selectParent then - element.selectParent.onChange = nil - end -end - ---- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the ---- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module. ----@param element Element The select parent whose child was removed. ----@param child Element The removed child. -function Select.handleChildRemoved(element, child) - if not element._selectState then - return - end - if element._selectState.selectFrame == child then - element._selectState.selectFrame = nil - element._selectState.expectedFrameParent = nil - element._selectState.frameAdopted = false - end - if element._selectState.selectAnchor == child then - element._selectState.selectAnchor = nil - end -end - ---- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame. ---- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation. ----@param element Element The managed-select frame (the dropdown container). ----@param child Element The flex child being measured. ----@param childBorderBoxWidth number Current computed border-box width of `child`. ----@return number Possibly-adjusted border-box width. -function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth) - if - element._managedSelectFrame - and element.autosizing - and element.autosizing.width - and child.units - and child.units.width - and child.units.width.unit == "%" - then - local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right - return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth) - end - return childBorderBoxWidth -end - -return Select diff --git a/libs/flexlove/modules/StateManager.lua b/libs/flexlove/modules/StateManager.lua deleted file mode 100644 index 3a7b2af7..00000000 --- a/libs/flexlove/modules/StateManager.lua +++ /dev/null @@ -1,790 +0,0 @@ ----@class StateManager -local StateManager = {} - --- ErrorHandler will be injected via init -local ErrorHandler - --- 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 = {} - --- Stateful element mapping: stateId -> element instance --- Used in retained mode for cache-through: StateManager resolves id -> element -> field -local statefulElements = {} - --- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame -local dirtyState = {} - --- Immediate mode flag -local _immediateMode = false - --- Configuration -local config = { - stateRetentionFrames = 2, -- Keep unused state for 2 frames - maxStateEntries = 1000, -- Maximum state entries before forced GC -} - --- Default state values (sparse storage - don't store these) -local stateDefaults = { - -- Interaction states - hover = false, - pressed = false, - focused = false, - disabled = false, - active = false, - - -- Scrollbar states - scrollbarHoveredVertical = false, - scrollbarHoveredHorizontal = false, - scrollbarDragging = false, - hoveredScrollbar = nil, - scrollbarDragOffset = 0, - dragStartMouseX = 0, - dragStartMouseY = 0, - dragStartScrollX = 0, - dragStartScrollY = 0, - - -- Scroll position - scrollX = 0, - scrollY = 0, - _scrollX = 0, - _scrollY = 0, - - -- Click tracking - _clickCount = 0, - _lastClickTime = nil, - _lastClickButton = nil, - - -- Internal states - _hovered = nil, - _focused = nil, - _cursorPosition = nil, - _selectionStart = nil, - _selectionEnd = nil, - _textBuffer = "", - _cursorBlinkTimer = 0, - _cursorVisible = true, - _cursorBlinkPaused = false, - _cursorBlinkPauseTimer = 0, -} - ---- Check if a value equals the default for a key ----@param key string State key ----@param value any Value to check ----@return boolean isDefault True if value equals default -local function isDefaultValue(key, value) - local defaultVal = stateDefaults[key] - - -- If no default defined, check for common defaults - if defaultVal == nil then - -- Empty tables are default - if type(value) == "table" and next(value) == nil then - return true - end - -- nil values are default - if value == nil then - return true - end - -- Otherwise, not a default value - return false - end - - -- Compare values - if type(value) == "table" then - -- Empty tables are considered default - if next(value) == nil then - return true - end - -- For other tables, compare contents (shallow) - if type(defaultVal) ~= "table" then - return false - end - for k, v in pairs(value) do - if defaultVal[k] ~= v then - return false - end - end - return true - else - return value == defaultVal - end -end - --- ==================== --- ID Generation --- ==================== - ---- 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 = { - onEvent = true, - parent = true, - children = true, - onFocus = true, - onBlur = true, - onTextInput = true, - onTextChange = true, - onEnter = true, - userdata = true, - -- Dynamic input/state properties that should not affect ID stability - text = true, -- Text content changes as user types - placeholder = true, -- Placeholder text is presentational - editable = true, -- Editable state can be toggled dynamically - selectOnFocus = true, -- Input behavior flag - autoGrow = true, -- Auto-grow behavior flag - passwordMode = true, -- Password mode can be toggled - } - - -- 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 - ---- Generate a unique ID from call site and properties ----@param props table|nil Optional properties to include in ID generation ----@param parent table|nil Optional parent element for tree-based ID generation ----@return string -function StateManager.generateID(props, parent) - -- 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 base location key from source file and line number - local filename = source:match("([^/\\]+)$") or source -- Get filename - filename = filename:gsub("%.lua$", "") -- Remove .lua extension - local locationKey = filename .. "_L" .. line - - -- If we have a parent, use tree-based ID generation for stability - if parent and parent.id and parent.id ~= "" then - -- For child elements, use call-site (file + line) like top-level elements - -- This ensures the same call site always generates the same ID, even when - -- retained children persist in parent.children array - local baseID = parent.id .. "_" .. locationKey - - -- Count how many children have been created at THIS call site - local callSiteKey = parent.id .. "_" .. locationKey - callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1 - local instanceNum = callSiteCounters[callSiteKey] - - 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 - - -- No parent (top-level element): use call-site counter approach - -- Track how many elements have been created at this location - callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1 - local instanceNum = callSiteCounters[locationKey] - - local 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 --- ==================== - ---- Initialize StateManager with dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler } -function StateManager.init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - end -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 StateManager.getState(id, defaultState) - if not id then - ErrorHandler:error("StateManager", "SYS_001", { - parameter = "id", - value = "nil", - }) - end - - -- Create state if it doesn't exist - if not stateStore[id] then - -- Start with empty state (sparse storage) - stateStore[id] = defaultState or {} - - -- Create metadata - stateMetadata[id] = { - lastFrame = frameNumber, - createdFrame = frameNumber, - accessCount = 0, - } - else - -- Update metadata - local meta = stateMetadata[id] - meta.lastFrame = frameNumber - meta.accessCount = meta.accessCount + 1 - end - - 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 - ErrorHandler:error("StateManager", "SYS_001", { - parameter = "id", - value = "nil", - }) - end - - -- Create sparse state (remove default values) - local sparseState = {} - for key, value in pairs(state) do - if not isDefaultValue(key, value) then - sparseState[key] = value - end - end - - stateStore[id] = sparseState - - -- 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) - - -- Merge new state into existing state (with diffing optimization) - local changed = false - for key, value in pairs(newState) do - if state[key] ~= value then - state[key] = value - changed = true - end - end - - -- Only update metadata if something actually changed - if changed then - stateMetadata[id].lastFrame = frameNumber - end -end - ---- Update state only if values have changed (optimized for immediate mode) ----@param id string Element ID ----@param newState table New state values to merge ----@return boolean changed True if any values changed -function StateManager.updateStateIfChanged(id, newState) - local state = StateManager.getState(id) - local changed = false - - for key, value in pairs(newState) do - -- Skip if value hasn't changed (optimization) - if state[key] ~= value then - state[key] = value - changed = true - end - end - - if changed then - stateMetadata[id].lastFrame = frameNumber - end - - return changed -end - ---- Clear state for a specific element ID ----@param id string Element ID -function StateManager.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 StateManager.markStateUsed(id) - if stateMetadata[id] then - stateMetadata[id].lastFrame = frameNumber - end -end - --- ==================== --- Frame Management --- ==================== - ---- Increment frame counter (called at frame start) -function StateManager.incrementFrame() - frameNumber = frameNumber + 1 - -- Reset call site counters for new frame - callSiteCounters = {} -end - ---- Get current frame number ----@return number -function StateManager.getFrameNumber() - return frameNumber -end - --- ==================== --- Granular State Access (Unified API for both modes) --- ==================== - ---- Get a single state value by key for a given element ID. ---- Works identically in both modes — the caller does not need to know the mode. ---- ---- Immediate mode: reads from persistent state store. ---- Retained mode: resolves through registered element field (cache-through). ---- ----@param id string Element state ID ----@param key string State key ----@return any value The stored value, or nil if not found -function StateManager.getStateValue(id, key) - if not id or not key then - ErrorHandler:error("StateManager", "SYS_001", { - parameter = "id and key", - value = "missing", - }) - end - - -- Update metadata for access tracking - if stateMetadata[id] then - stateMetadata[id].lastFrame = frameNumber - stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1 - end - - if _immediateMode then - -- Immediate mode: read from persistent state store - local state = stateStore[id] - if state then - return state[key] - end - return nil - else - -- Retained mode: resolve through element field - local element = statefulElements[id] - if element then - return element[key] - end - return nil - end -end - ---- Set a single state value by key for a given element ID. ---- Works identically in both modes — the caller does not need to know the mode. ---- ---- Immediate mode: marks dirty for flushFrame() persistence. ---- Retained mode: writes directly to element field (cache-through). ---- ----@param id string Element state ID ----@param key string State key ----@param value any Value to store -function StateManager.setStateValue(id, key, value) - if not id or not key then - ErrorHandler:error("StateManager", "SYS_001", { - parameter = "id and key", - value = "missing", - }) - end - - -- Update metadata - if not stateMetadata[id] then - stateMetadata[id] = { - lastFrame = frameNumber, - createdFrame = frameNumber, - accessCount = 1, - } - else - stateMetadata[id].lastFrame = frameNumber - end - - if _immediateMode then - -- Immediate mode: mark dirty for flushFrame persistence - local state = StateManager.getState(id) - state[key] = value - dirtyState[id] = dirtyState[id] or {} - dirtyState[id][key] = true - else - -- Retained mode: write directly to element field - local element = statefulElements[id] - if element then - element[key] = value - end - end -end - --- ==================== --- Stateful Element Registration (Retained Mode Cache-Through) --- ==================== - ---- Register an element instance for retained-mode cache-through. ---- After registration, getStateValue/setStateValue will resolve through the element's fields. ---- ---- Called by Element in _construct phase. ---- ----@param id string State ID (typically element.id) ----@param element table Element instance to link -function StateManager.registerStateful(id, element) - if not id or not element then - return - end - statefulElements[id] = element -end - ---- Unregister an element instance. ---- After unregistration, retained-mode access will fall back to nil. ---- ---- Called by Element in _cleanup phase. ---- ----@param id string State ID to unregister -function StateManager.unregisterStateful(id) - if id then - statefulElements[id] = nil - end -end - --- ==================== --- Frame Flush (Immediate Mode Dirty State Persistence) --- ==================== - ---- Flush dirty state to persistent store at end of frame. ---- Called automatically at frame end in immediate mode. ---- Behaviors call setStateValue during update without knowing the mode. ---- ---- In retained mode, this is a no-op (state is written directly to elements). -function StateManager.flushFrame() - if not _immediateMode then - return - end - - -- All dirty writes were already applied to stateStore during setStateValue - -- This method exists for future extensions (e.g., batching, analytics) - -- Reset dirty tracking for next frame - dirtyState = {} -end - --- ==================== --- Mode Configuration --- ==================== - ---- Configure immediate mode state. ---- Called by Context when immediate mode is enabled/disabled. ---- ----@param enabled boolean Whether immediate mode is active -function StateManager.setImmediateMode(enabled) - _immediateMode = enabled -end - ---- Check if immediate mode is active. ----@return boolean -function StateManager.isImmediateMode() - return _immediateMode -end - ---- Whether at-construction layout / eager initialization should run now. ---- Returns true in retained mode (layout eagerly), false in immediate mode ---- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all ---- elements for the frame have been created). This replaces the scattered ---- `if not _immediateMode then layoutChildren()` mode checks with a single ---- mode-aware query (behavior-mode-unification task 11). ----@return boolean -function StateManager.shouldLayout() - return not _immediateMode -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 - - 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 - - -- Clean up empty states (sparse storage optimization) - for id, state in pairs(stateStore) do - if next(state) == nil 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 StateManager.forceCleanupIfNeeded() - 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 - - return cleanedCount - end - - return 0 -end - ---- Get total number of stored states ----@return number -function StateManager.getStateCount() - local count = 0 - for _ in pairs(stateStore) do - count = count + 1 - end - return count -end - ---- Clear all states -function StateManager.clearAllStates() - stateStore = {} - 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 -end - ---- Get state statistics for debugging ----@return table stats State usage statistics -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 - - -- Count callSiteCounters - local callSiteCount = 0 - for _ in pairs(callSiteCounters) do - callSiteCount = callSiteCount + 1 - end - - -- Warn if callSiteCounters is unexpectedly large - if callSiteCount > 1000 then - if ErrorHandler then - ErrorHandler.warn("StateManager", "STATE_001", { - count = callSiteCount, - expected = "near 0", - frameNumber = frameNumber, - }) - end - end - - return { - stateCount = stateCount, - frameNumber = frameNumber, - oldestState = oldest, - newestState = newest, - callSiteCounterCount = callSiteCount, - } -end - ---- Get internal state (for debugging/profiling only) ----@return table internal {stateStore, stateMetadata, callSiteCounters} -function StateManager._getInternalState() - return { - stateStore = stateStore, - stateMetadata = stateMetadata, - callSiteCounters = callSiteCounters, - } -end - ---- Reset the entire state system (for testing) -function StateManager.reset() - stateStore = {} - stateMetadata = {} - frameNumber = 0 - callSiteCounters = {} - statefulElements = {} - dirtyState = {} - _immediateMode = false -end - --- ==================== --- Convenience Functions (for backward compatibility) --- ==================== - ---- Check if an element is currently hovered ----@param id string Element ID ----@return boolean -function StateManager.isHovered(id) - local state = StateManager.getState(id) - return state.hover or false -end - ---- Check if an element is currently pressed ----@param id string Element ID ----@return boolean -function StateManager.isPressed(id) - local state = StateManager.getState(id) - return state.pressed or false -end - ---- Check if an element is currently focused ----@param id string Element ID ----@return boolean -function StateManager.isFocused(id) - local state = StateManager.getState(id) - return state.focused or false -end - ---- Check if an element is disabled ----@param id string Element ID ----@return boolean -function StateManager.isDisabled(id) - local state = StateManager.getState(id) - return state.disabled or false -end - ---- Check if an element is active (e.g., input focused) ----@param id string Element ID ----@return boolean -function StateManager.isActive(id) - local state = StateManager.getState(id) - return state.active or false -end - -return StateManager diff --git a/libs/flexlove/modules/TextEditor.lua b/libs/flexlove/modules/TextEditor.lua deleted file mode 100644 index 35af6354..00000000 --- a/libs/flexlove/modules/TextEditor.lua +++ /dev/null @@ -1,1783 +0,0 @@ -local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8") -local utf8 = UTF8 - ----@class TextEditor ----@field editable boolean ----@field multiline boolean ----@field passwordMode boolean ----@field textWrap boolean|"word"|"char" ----@field maxLines number? ----@field maxLength number? ----@field placeholder string? ----@field inputType "text"|"number"|"email"|"url" ----@field textOverflow "clip"|"ellipsis"|"scroll" ----@field scrollable boolean ----@field autoGrow boolean ----@field selectOnFocus boolean ----@field sanitize boolean ----@field allowNewlines boolean ----@field allowTabs boolean ----@field customSanitizer function? ----@field cursorColor Color? ----@field selectionColor Color? ----@field cursorBlinkRate number ----@field _textBuffer string ----@field _lines table? ----@field _wrappedLines table? ----@field _textDirty boolean ----@field _cursorPosition number ----@field _cursorLine number ----@field _cursorColumn number ----@field _cursorBlinkTimer number ----@field _cursorVisible boolean ----@field _cursorBlinkPaused boolean ----@field _cursorBlinkPauseTimer number ----@field _selectionStart number? ----@field _selectionEnd number? ----@field _selectionAnchor number? ----@field _focused boolean ----@field _textScrollX number ----@field onFocus fun(element:Element)? ----@field onBlur fun(element:Element)? ----@field onTextInput fun(element:Element, text:string)? ----@field onTextChange fun(element:Element, text:string)? ----@field onEnter fun(element:Element)? ----@field onSanitize fun(element:Element, original:string, sanitized:string)? ----@field _Context table ----@field _StateManager table ----@field _Color table ----@field _FONT_CACHE table ----@field _getModifiers function ----@field _utils table ----@field _textDragOccurred boolean? -local TextEditor = {} -TextEditor.__index = TextEditor - ----@class TextEditorConfig ----@field editable boolean -- Whether text is editable ----@field multiline boolean -- Whether multi-line is supported ----@field passwordMode boolean -- Whether to mask text ----@field textWrap boolean|"word"|"char" -- Text wrapping mode ----@field maxLines number? -- Maximum number of lines ----@field maxLength number? -- Maximum text length in characters ----@field placeholder string? -- Placeholder text when empty ----@field inputType "text"|"number"|"email"|"url" -- Input validation type ----@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior ----@field scrollable boolean -- Whether text is scrollable ----@field autoGrow boolean -- Whether element auto-grows with text ----@field selectOnFocus boolean -- Whether to select all text on focus ----@field sanitize boolean? -- Whether to sanitize text input (default: true) ----@field allowNewlines boolean? -- Whether to allow newline characters (default: true in multiline) ----@field allowTabs boolean? -- Whether to allow tab characters (default: true) ----@field customSanitizer function? -- Custom sanitization function ----@field cursorColor Color? -- Cursor color ----@field selectionColor Color? -- Selection background color ----@field cursorBlinkRate number -- Cursor blink rate in seconds - ----Create a new TextEditor instance ----@param config TextEditorConfig ----@param deps table Dependencies {Context, StateManager, Color, utils} ----@return table TextEditor instance -function TextEditor.new(config, deps) - local self = setmetatable({}, TextEditor) - - -- Store dependencies - self._Context = deps.Context - self._StateManager = deps.StateManager - self._Color = deps.Color - self._FONT_CACHE = deps.utils.FONT_CACHE - self._getModifiers = deps.utils.getModifiers - self._utils = deps.utils - - -- Store configuration - self.editable = config.editable or false - self.multiline = config.multiline or false - self.passwordMode = config.passwordMode or false - self.textWrap = config.textWrap - self.maxLines = config.maxLines - self.maxLength = config.maxLength - self.placeholder = config.placeholder - self.inputType = config.inputType or "text" - self.textOverflow = config.textOverflow or "clip" - self.scrollable = config.scrollable - self.autoGrow = config.autoGrow - self.selectOnFocus = config.selectOnFocus or false - self.cursorColor = config.cursorColor - self.selectionColor = config.selectionColor - self.cursorBlinkRate = config.cursorBlinkRate or 0.5 - - -- Sanitization configuration - self.sanitize = config.sanitize ~= false -- Default to true - -- If allowNewlines is explicitly set, use that value; otherwise follow multiline setting - if config.allowNewlines ~= nil then - self.allowNewlines = config.allowNewlines - else - self.allowNewlines = self.multiline - end - self.allowTabs = config.allowTabs ~= false -- Default to true - self.customSanitizer = config.customSanitizer - - -- Initialize text buffer state (with sanitization) - local initialText = config.text or "" - self._textBuffer = self:_sanitizeText(initialText) - self._lines = nil - self._wrappedLines = nil - self._textDirty = true - - -- Initialize cursor state - self._cursorPosition = 0 - self._cursorLine = 1 - self._cursorColumn = 0 - self._cursorBlinkTimer = 0 - self._cursorVisible = true - self._cursorBlinkPaused = false - self._cursorBlinkPauseTimer = 0 - - -- Initialize selection state - self._selectionStart = nil - self._selectionEnd = nil - self._selectionAnchor = nil - - -- Initialize focus state - self._focused = false - - -- Initialize scroll state - self._textScrollX = 0 - - -- Store callbacks - self.onFocus = config.onFocus - self.onBlur = config.onBlur - self.onTextInput = config.onTextInput - self.onTextChange = config.onTextChange - self.onEnter = config.onEnter - self.onSanitize = config.onSanitize - - return self -end - ----Internal: Sanitize text input ----@param text string -- Text to sanitize ----@return string -- Sanitized text -function TextEditor:_sanitizeText(text) - if not self.sanitize then - return text - end - - -- Use custom sanitizer if provided - if self.customSanitizer then - return self.customSanitizer(text) or text - end - - local options = { - maxLength = self.maxLength, - allowNewlines = self.allowNewlines, - allowTabs = self.allowTabs, - trimWhitespace = false, -- Preserve whitespace in text editors - } - - local sanitized = self._utils.sanitizeText(text, options) - - return sanitized -end - ----Restore state from StateManager (for immediate mode) ----@param element table The parent Element instance -function TextEditor:restoreState(element) - -- Restore state from StateManager. Mode-aware via Context.isImmediateMode: - -- in retained mode the TextEditor persists between frames so nothing to - -- restore (behavior-mode-unification task 11). - if element._stateId and self._Context.isImmediateMode() then - local state = self._StateManager.getState(element._stateId) - if state then - if state._focused then - self._focused = true - self._Context.setFocused(element) - end - if state._textBuffer and state._textBuffer ~= "" then - self._textBuffer = state._textBuffer - end - if state._cursorPosition then - self._cursorPosition = state._cursorPosition - end - if state._selectionStart then - self._selectionStart = state._selectionStart - end - if state._selectionEnd then - self._selectionEnd = state._selectionEnd - end - if state._cursorBlinkTimer 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 then - self._cursorBlinkPauseTimer = state._cursorBlinkPauseTimer - end - end - end -end - --- ==================== --- Text Buffer Management --- ==================== - ----Get current text buffer ----@return string -function TextEditor:getText() - return self._textBuffer or "" -end - ----Set text buffer and mark dirty ----@param element Element? The parent element (for state saving) ----@param text string ----@param skipSanitization boolean? -- Skip sanitization (for trusted input) -function TextEditor:setText(element, text, skipSanitization) - text = text or "" - - -- Sanitize text unless explicitly skipped - if not skipSanitization then - local originalText = text - text = self:_sanitizeText(text) - - -- Trigger onSanitize callback if text was sanitized - if text ~= originalText and self.onSanitize and element then - self.onSanitize(element, originalText, text) - end - end - - self._textBuffer = text - self:_markTextDirty() - self:_updateTextIfDirty(element) - self:_validateCursorPosition() - self:_saveState(element) -end - ----Insert text at position ----@param element Element The parent element (for state saving) ----@param text string -- Text to insert ----@param position number? -- Position to insert at (default: cursor position) ----@param skipSanitization boolean? -- Skip sanitization (for internal use) -function TextEditor:insertText(element, text, position, skipSanitization) - position = position or self._cursorPosition - local buffer = self._textBuffer or "" - - -- Sanitize text unless explicitly skipped - if not skipSanitization then - text = self:_sanitizeText(text) - end - - -- Check if text is empty after sanitization - if not text or text == "" then - return - end - - -- Check maxLength constraint before inserting - if self.maxLength then - local currentLength = utf8.len(buffer) or 0 - local textLength = utf8.len(text) or 0 - local newLength = currentLength + textLength - - if newLength > self.maxLength then - -- Truncate text to fit - local remaining = self.maxLength - currentLength - if remaining <= 0 then - return - end - -- Truncate to remaining characters - local truncated = "" - local count = 0 - for _, code in utf8.codes(text) do - if count >= remaining then - break - end - truncated = truncated .. utf8.char(code) - count = count + 1 - end - text = truncated - end - end - - -- Convert character position to byte offset - local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1) - - -- Insert text - local before = buffer:sub(1, byteOffset - 1) - local after = buffer:sub(byteOffset) - self._textBuffer = before .. text .. after - - self._cursorPosition = position + utf8.len(text) - - self:_markTextDirty() - self:_updateTextIfDirty(element) - self:_validateCursorPosition() - self:_resetCursorBlink(element, true) - self:_saveState(element) -end - ----Delete text in range ----@param element Element The parent element (for state saving) ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function TextEditor:deleteText(element, startPos, endPos) - local buffer = self._textBuffer or "" - - -- Ensure valid range - local textLength = utf8.len(buffer) - startPos = math.max(0, math.min(startPos, textLength)) - endPos = math.max(0, math.min(endPos, textLength)) - - if startPos > endPos then - startPos, endPos = endPos, startPos - end - - -- Convert character positions to byte offsets - local startByte = utf8.offset(buffer, startPos + 1) or 1 - local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1) - - -- Delete text - local before = buffer:sub(1, startByte - 1) - local after = buffer:sub(endByte) - self._textBuffer = before .. after - - self:_markTextDirty() - self:_updateTextIfDirty(element) - self:_resetCursorBlink(element, true) - self:_saveState(element) -end - ----Replace text in range ----@param element Element The parent element (for state saving) ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) ----@param newText string -- Replacement text -function TextEditor:replaceText(element, startPos, endPos, newText) - self:deleteText(element, startPos, endPos) - self:insertText(element, newText, startPos) -end - ----Mark text as dirty (needs recalculation) -function TextEditor:_markTextDirty() - self._textDirty = true -end - ----Update text if dirty (recalculate lines and wrapping) ----@param element Element? The parent element (for wrapping calculations) -function TextEditor:_updateTextIfDirty(element) - if not self._textDirty then - return - end - - self:_splitLines() - self:_calculateWrapping(element) - self:_validateCursorPosition() - self._textDirty = false -end - --- ==================== --- Line Splitting and Wrapping --- ==================== - ----Split text into lines (for multi-line text) -function TextEditor:_splitLines() - if not self.multiline then - self._lines = { self._textBuffer or "" } - return - end - - self._lines = {} - local text = self._textBuffer or "" - - -- Split on newlines - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(self._lines, line) - end - - -- Ensure at least one line - if #self._lines == 0 then - self._lines = { "" } - end -end - ----Calculate text wrapping ----@param element Element? The parent element -function TextEditor:_calculateWrapping(element) - if not self.textWrap or not element then - self._wrappedLines = nil - return - end - - self._wrappedLines = {} - local availableWidth = element.width - element.padding.left - element.padding.right - - for lineNum, line in ipairs(self._lines or {}) do - if line == "" then - table.insert(self._wrappedLines, { - text = "", - startIdx = 0, - endIdx = 0, - lineNum = lineNum, - }) - else - local wrappedParts = self:_wrapLine(element, line, availableWidth) - for _, part in ipairs(wrappedParts) do - part.lineNum = lineNum - table.insert(self._wrappedLines, part) - end - end - end -end - ----Wrap a single line of text ----@param element Element The parent element ----@param line string -- Line to wrap ----@param maxWidth number -- Maximum width in pixels ----@return table -- Array of wrapped line parts -function TextEditor:_wrapLine(element, line, maxWidth) - if not element then - return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } - end - - -- Delegate to Renderer - return element._renderer:wrapLine(element, line, maxWidth) -end - --- ==================== --- Cursor Management --- ==================== - ----Set cursor position ----@param element Element? The parent element (for scroll updates) ----@param position number -- Character index (0-based) -function TextEditor:setCursorPosition(element, position) - self._cursorPosition = position - self:_validateCursorPosition() - self:_resetCursorBlink(element) -end - ----Get cursor position ----@return number -- Character index (0-based) -function TextEditor:getCursorPosition() - return self._cursorPosition -end - ----Move cursor by delta characters ----@param element Element? The parent element (for scroll updates) ----@param delta number -- Number of characters to move (positive or negative) -function TextEditor:moveCursorBy(element, delta) - self._cursorPosition = self._cursorPosition + delta - self:_validateCursorPosition() - self:_resetCursorBlink(element) -end - ----Move cursor to start of text ----@param element Element? The parent element (for scroll updates) -function TextEditor:moveCursorToStart(element) - self._cursorPosition = 0 - self:_resetCursorBlink(element) -end - ----Move cursor to end of text ----@param element Element? The parent element (for scroll updates) -function TextEditor:moveCursorToEnd(element) - local textLength = utf8.len(self._textBuffer or "") - self._cursorPosition = textLength - self:_resetCursorBlink(element) -end - ----Move cursor to start of current line ----@param element Element? The parent element (for scroll updates) -function TextEditor:moveCursorToLineStart(element) - -- For now, just move to start (will be enhanced for multi-line) - self:moveCursorToStart(element) -end - ----Move cursor to end of current line ----@param element Element? The parent element (for scroll updates) -function TextEditor:moveCursorToLineEnd(element) - -- For now, just move to end (will be enhanced for multi-line) - self:moveCursorToEnd(element) -end - ----Move cursor to start of previous word -function TextEditor:moveCursorToPreviousWord() - if not self._textBuffer then - return - end - - local text = self._textBuffer - local pos = self._cursorPosition - - if pos <= 0 then - return - end - - -- Helper function to get character at position - local function getCharAt(p) - if p < 0 or p >= utf8.len(text) then - return nil - end - local offset1 = utf8.offset(text, p + 1) - local offset2 = utf8.offset(text, p + 2) - if not offset1 then - return nil - end - if not offset2 then - return text:sub(offset1) - end - return text:sub(offset1, offset2 - 1) - end - - -- Skip any whitespace/punctuation before current position - while pos > 0 do - local char = getCharAt(pos - 1) - if char and char:match("[%w]") then - break - end - pos = pos - 1 - end - - -- Move to start of current word - while pos > 0 do - local char = getCharAt(pos - 1) - if not char or not char:match("[%w]") then - break - end - pos = pos - 1 - end - - self._cursorPosition = pos - self:_validateCursorPosition() -end - ----Move cursor to start of next word -function TextEditor:moveCursorToNextWord() - if not self._textBuffer then - return - end - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - local pos = self._cursorPosition - - if pos >= textLength then - return - end - - -- Helper function to get character at position - local function getCharAt(p) - if p < 0 or p >= textLength then - return nil - end - local offset1 = utf8.offset(text, p + 1) - local offset2 = utf8.offset(text, p + 2) - if not offset1 then - return nil - end - if not offset2 then - return text:sub(offset1) - end - return text:sub(offset1, offset2 - 1) - end - - -- Skip current word - while pos < textLength do - local char = getCharAt(pos) - if not char or not char:match("[%w]") then - break - end - pos = pos + 1 - end - - -- Skip any whitespace/punctuation - while pos < textLength do - local char = getCharAt(pos) - if char and char:match("[%w]") then - break - end - pos = pos + 1 - end - - self._cursorPosition = pos - self:_validateCursorPosition() -end - ----Validate cursor position (ensure it's within text bounds) -function TextEditor:_validateCursorPosition() - local textLength = utf8.len(self._textBuffer or "") or 0 - local cursorPos = tonumber(self._cursorPosition) or 0 - self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) -end - ----Reset cursor blink (show cursor immediately) ----@param element Element? The parent element (for scroll updates) ----@param pauseBlink boolean|nil -- Whether to pause blinking (for typing) -function TextEditor:_resetCursorBlink(element, pauseBlink) - self._cursorBlinkTimer = 0 - self._cursorVisible = true - - if pauseBlink then - self._cursorBlinkPaused = true - self._cursorBlinkPauseTimer = 0 - end - - self:_updateTextScroll(element) -end - ----Update text scroll offset to keep cursor visible ----@param element Element? The parent element -function TextEditor:_updateTextScroll(element) - if not element or self.multiline then - return - end - - local font = self:_getFont(element) - if not font then - return - end - - -- Calculate cursor X position in text coordinates - local cursorText = "" - if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then - local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1) - if byteOffset then - cursorText = self._textBuffer:sub(1, byteOffset - 1) - end - end - local cursorX = font:getWidth(cursorText) - - -- Get available text area width - local textAreaWidth = element.width - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Add some padding on the right for the cursor - local cursorPadding = 4 - local visibleWidth = textAreaWidth - cursorPadding - - -- Adjust scroll to keep cursor visible - if cursorX - self._textScrollX < 0 then - self._textScrollX = cursorX - elseif cursorX - self._textScrollX > visibleWidth then - self._textScrollX = cursorX - visibleWidth - end - - -- Ensure we don't scroll past the beginning - self._textScrollX = math.max(0, self._textScrollX) -end - ----Get cursor screen position for rendering (handles multiline text) ----@param element Element? The parent element ----@return number, number -- Cursor X and Y position relative to content area -function TextEditor:_getCursorScreenPosition(element) - local font = self:_getFont(element) - if not font then - return 0, 0 - end - - local text = self._textBuffer or "" - local cursorPos = self._cursorPosition or 0 - - -- Apply password masking for cursor position calculation - local textForMeasurement = text - if self.passwordMode and text ~= "" then - textForMeasurement = string.rep("•", utf8.len(text)) - end - - -- For single-line text, calculate simple X position - if not self.multiline then - local cursorText = "" - if textForMeasurement ~= "" and cursorPos > 0 then - local byteOffset = utf8.offset(textForMeasurement, cursorPos + 1) - if byteOffset then - cursorText = textForMeasurement:sub(1, byteOffset - 1) - end - end - return font:getWidth(cursorText), 0 - end - - -- For multiline text, we need to find which wrapped line the cursor is on - self:_updateTextIfDirty(element) - - if not element then - return 0, 0 - end - - -- Get text area width for wrapping - local textAreaWidth = element.width - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Split text by actual newlines first - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - -- Track character position as we iterate through lines - local charCount = 0 - local cursorX = 0 - local cursorY = 0 - local lineHeight = font:getHeight() - - for lineNum, line in ipairs(lines) do - local lineLength = utf8.len(line) or 0 - - -- Check if cursor is on this line - if cursorPos <= charCount + lineLength then - local posInLine = cursorPos - charCount - - -- If text wrapping is enabled, find which wrapped segment - if self.textWrap and textAreaWidth > 0 then - local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) - - for segmentIdx, segment in ipairs(wrappedSegments) do - if posInLine >= segment.startIdx and posInLine <= segment.endIdx then - local posInSegment = posInLine - segment.startIdx - local segmentText = "" - if posInSegment > 0 and segment.text ~= "" then - local endByte = utf8.offset(segment.text, posInSegment + 1) - if endByte then - segmentText = segment.text:sub(1, endByte - 1) - else - segmentText = segment.text - end - end - cursorX = font:getWidth(segmentText) - cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight - - return cursorX, cursorY - end - end - else - -- No wrapping, simple calculation - local lineText = "" - if posInLine > 0 then - local endByte = utf8.offset(line, posInLine + 1) - if endByte then - lineText = line:sub(1, endByte - 1) - else - lineText = line - end - end - cursorX = font:getWidth(lineText) - cursorY = (lineNum - 1) * lineHeight - return cursorX, cursorY - end - end - - charCount = charCount + lineLength + 1 - end - - -- Cursor is at the very end - return 0, #lines * lineHeight -end - --- ==================== --- Selection Management --- ==================== - ----Set selection range ----@param element Element? The parent element (for scroll updates) ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function TextEditor:setSelection(element, startPos, endPos) - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = math.max(0, math.min(startPos, textLength)) - self._selectionEnd = math.max(0, math.min(endPos, textLength)) - - -- Ensure start <= end - if self._selectionStart > self._selectionEnd then - self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart - end - - self:_resetCursorBlink(element) -end - ----Get selection range ----@return number?, number? -- Start and end positions, or nil if no selection -function TextEditor:getSelection() - if not self:hasSelection() then - return nil, nil - end - return self._selectionStart, self._selectionEnd -end - ----Check if there is an active selection ----@return boolean -function TextEditor:hasSelection() - return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd -end - ----Clear selection -function TextEditor:clearSelection() - self._selectionStart = nil - self._selectionEnd = nil - self._selectionAnchor = nil -end - ----Select all text ----@param element Element? The parent element (for scroll updates) -function TextEditor:selectAll(element) - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = 0 - self._selectionEnd = textLength - self:_resetCursorBlink(element) -end - ----Get selected text ----@return string? -- Selected text or nil if no selection -function TextEditor:getSelectedText() - if not self:hasSelection() then - return nil - end - - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return nil - end - - -- Convert character indices to byte offsets - local text = self._textBuffer or "" - local startByte = utf8.offset(text, startPos + 1) - local endByte = utf8.offset(text, endPos + 1) - - if not startByte then - return "" - end - - if endByte then - endByte = endByte - 1 - end - - return string.sub(text, startByte, endByte) -end - ----Delete selected text ----@param element Element The parent element (for state saving) ----@return boolean -- True if text was deleted -function TextEditor:deleteSelection(element) - if not self:hasSelection() then - return false - end - - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return false - end - - self:deleteText(element, startPos, endPos) - self:clearSelection() - self._cursorPosition = startPos - self:_validateCursorPosition() - self:_saveState(element) - - -- Sync display text and auto-grow height on the owning element - if element then - element.text = self:getText() - self:updateAutoGrowHeight(element) - end - - return true -end - ----Get selection rectangles for rendering ----@param element Element The parent element ----@param selStart number -- Selection start position ----@param selEnd number -- Selection end position ----@return table -- Array of rectangles {x, y, width, height} -function TextEditor:_getSelectionRects(element, selStart, selEnd) - local font = self:_getFont(element) - if not font or not element then - return {} - end - - local text = self._textBuffer or "" - local rects = {} - - -- Apply password masking - local textForMeasurement = text - if self.passwordMode and text ~= "" then - textForMeasurement = string.rep("•", utf8.len(text)) - end - - -- For single-line text, calculate simple rectangle - if not self.multiline then - local startByte = utf8.offset(textForMeasurement, selStart + 1) - local endByte = utf8.offset(textForMeasurement, selEnd + 1) - - if startByte and endByte then - local beforeSelection = textForMeasurement:sub(1, startByte - 1) - local selectedText = textForMeasurement:sub(startByte, endByte - 1) - local selX = font:getWidth(beforeSelection) - local selWidth = font:getWidth(selectedText) - local selY = 0 - local selHeight = font:getHeight() - - table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) - end - - return rects - end - - -- For multiline text, handle line wrapping - self:_updateTextIfDirty(element) - - -- Get text area width for wrapping - local textAreaWidth = element.width - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Split text by actual newlines - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - local lineHeight = font:getHeight() - local charCount = 0 - local visualLineNum = 0 - - for lineNum, line in ipairs(lines) do - local lineLength = utf8.len(line) or 0 - local lineStartChar = charCount - local lineEndChar = charCount + lineLength - - if selEnd > lineStartChar and selStart <= lineEndChar then - local selStartInLine = math.max(0, selStart - charCount) - local selEndInLine = math.min(lineLength, selEnd - charCount) - - if self.textWrap and textAreaWidth > 0 then - local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) - - for segmentIdx, segment in ipairs(wrappedSegments) do - if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then - local segSelStart = math.max(segment.startIdx, selStartInLine) - local segSelEnd = math.min(segment.endIdx, selEndInLine) - - local beforeText = "" - local selectedText = "" - - if segSelStart > segment.startIdx then - local startByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) - if startByte then - beforeText = segment.text:sub(1, startByte - 1) - end - end - - local selStartByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) - local selEndByte = utf8.offset(segment.text, segSelEnd - segment.startIdx + 1) - if selStartByte and selEndByte then - selectedText = segment.text:sub(selStartByte, selEndByte - 1) - end - - local selX = font:getWidth(beforeText) - local selWidth = font:getWidth(selectedText) - local selY = visualLineNum * lineHeight - local selHeight = lineHeight - - table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) - end - - visualLineNum = visualLineNum + 1 - end - else - -- No wrapping - local beforeText = "" - local selectedText = "" - - if selStartInLine > 0 then - local startByte = utf8.offset(line, selStartInLine + 1) - if startByte then - beforeText = line:sub(1, startByte - 1) - end - end - - local selStartByte = utf8.offset(line, selStartInLine + 1) - local selEndByte = utf8.offset(line, selEndInLine + 1) - if selStartByte and selEndByte then - selectedText = line:sub(selStartByte, selEndByte - 1) - end - - local selX = font:getWidth(beforeText) - local selWidth = font:getWidth(selectedText) - local selY = visualLineNum * lineHeight - local selHeight = lineHeight - - table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) - visualLineNum = visualLineNum + 1 - end - else - -- Selection doesn't intersect, but count visual lines - if self.textWrap and textAreaWidth > 0 then - local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) - visualLineNum = visualLineNum + #wrappedSegments - else - visualLineNum = visualLineNum + 1 - end - end - - charCount = charCount + lineLength + 1 - end - - return rects -end - --- ==================== --- Focus Management --- ==================== - ----Focus this element for keyboard input ----@param element Element The parent element -function TextEditor:focus(element) - if not element then - return - end - - -- Use centralized Context focus management - self._Context.setFocused(element) - self._focused = true - - self:_resetCursorBlink(element) - - if self.selectOnFocus then - self:selectAll(element) - else - self:moveCursorToEnd(element) - end - - if self.onFocus then - self.onFocus(element) - end - - self:_saveState(element) -end - ----Remove focus from this element ----@param element Element The parent element -function TextEditor:blur(element) - if not element then - return - end - - self._focused = false - - -- Clear focused element in Context if this element is currently focused - -- Use direct assignment to avoid circular call back to blur() - if self._Context.getFocused() == element then - self._Context._focusedElement = nil - end - - if self.onBlur then - self.onBlur(element) - end - - self:_saveState(element) -end - ----Check if this element is focused ----@return boolean -function TextEditor:isFocused() - return self._focused == true -end - --- ==================== --- Input Handling --- ==================== - ----Handle text input (character insertion) ----@param element Element The parent element ----@param text string -function TextEditor:handleTextInput(element, text) - if not self._focused then - return - end - - -- Trigger onTextInput callback if defined - if self.onTextInput then - local result = self.onTextInput(element, text) - if result == false then - return - end - end - - local oldText = self._textBuffer - - -- Delete selection if exists - if self:hasSelection() then - self:deleteSelection(element) - end - - -- Insert text at cursor position - self:insertText(element, text) - -- Trigger onTextChange callback - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - - self:_saveState(element) -end - ----Handle key press (special keys) ----@param element Element The parent element ----@param key string -- Key name ----@param scancode string -- Scancode ----@param isrepeat boolean -- Whether this is a key repeat -function TextEditor:handleKeyPress(element, key, scancode, isrepeat) - if not self._focused then - return - end - - local modifiers = self._getModifiers() - local ctrl = modifiers.ctrl or modifiers.super - - -- Handle cursor movement with selection - if key == "left" or key == "right" or key == "home" or key == "end" or key == "up" or key == "down" then - if modifiers.shift and not self._selectionAnchor then - self._selectionAnchor = self._cursorPosition - end - - if key == "left" then - if modifiers.super then - self:moveCursorToStart(element) - if not modifiers.shift then - self:clearSelection() - end - elseif modifiers.alt then - self:moveCursorToPreviousWord() - elseif self:hasSelection() and not modifiers.shift then - local startPos, _ = self:getSelection() - self._cursorPosition = startPos - self:clearSelection() - else - self:moveCursorBy(element, -1) - end - elseif key == "right" then - if modifiers.super then - self:moveCursorToEnd(element) - if not modifiers.shift then - self:clearSelection() - end - elseif modifiers.alt then - self:moveCursorToNextWord() - elseif self:hasSelection() and not modifiers.shift then - local _, endPos = self:getSelection() - self._cursorPosition = endPos - self:clearSelection() - else - self:moveCursorBy(element, 1) - end - elseif key == "home" then - if not self.multiline then - self:moveCursorToStart(element) - else - self:moveCursorToLineStart(element) - end - if not modifiers.shift then - self:clearSelection() - end - elseif key == "end" then - if not self.multiline then - self:moveCursorToEnd(element) - else - self:moveCursorToLineEnd(element) - end - if not modifiers.shift then - self:clearSelection() - end - elseif key == "up" or key == "down" then - if not modifiers.shift then - self:clearSelection() - end - end - - -- Update selection if Shift is pressed - if modifiers.shift and self._selectionAnchor then - self:setSelection(element, self._selectionAnchor, self._cursorPosition) - elseif not modifiers.shift then - self._selectionAnchor = nil - end - - self:_resetCursorBlink(element) - - -- Handle backspace and delete - elseif key == "backspace" then - local oldText = self._textBuffer - if self:hasSelection() then - self:deleteSelection(element) - elseif ctrl then - if self._cursorPosition > 0 then - self:deleteText(element, 0, self._cursorPosition) - self._cursorPosition = 0 - self:_validateCursorPosition() - end - elseif self._cursorPosition > 0 then - local deleteStart = self._cursorPosition - 1 - local deleteEnd = self._cursorPosition - self._cursorPosition = deleteStart - self:deleteText(element, deleteStart, deleteEnd) - self:_validateCursorPosition() - end - - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - self:_resetCursorBlink(element, true) - elseif key == "delete" then - local oldText = self._textBuffer - if self:hasSelection() then - self:deleteSelection(element) - else - local textLength = utf8.len(self._textBuffer or "") - if self._cursorPosition < textLength then - self:deleteText(element, self._cursorPosition, self._cursorPosition + 1) - end - end - - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - self:_resetCursorBlink(element, true) - - -- Handle return/enter - elseif key == "return" or key == "kpenter" then - if self.multiline then - local oldText = self._textBuffer - if self:hasSelection() then - self:deleteSelection(element) - end - self:insertText(element, "\n") - - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - else - if self.onEnter then - self.onEnter(element) - end - end - self:_resetCursorBlink(element, true) - - -- Handle Ctrl/Cmd+A (select all) - elseif ctrl and key == "a" then - self:selectAll(element) - self:_resetCursorBlink(element) - - -- Handle Ctrl/Cmd+C (copy) - elseif ctrl and key == "c" then - if self:hasSelection() then - local selectedText = self:getSelectedText() - if selectedText then - love.system.setClipboardText(selectedText) - end - end - self:_resetCursorBlink(element) - - -- Handle Ctrl/Cmd+X (cut) - elseif ctrl and key == "x" then - if self:hasSelection() then - local selectedText = self:getSelectedText() - if selectedText then - love.system.setClipboardText(selectedText) - - local oldText = self._textBuffer - self:deleteSelection(element) - - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - end - end - self:_resetCursorBlink(element, true) - - -- Handle Ctrl/Cmd+V (paste) - elseif ctrl and key == "v" then - local clipboardText = love.system.getClipboardText() - if clipboardText and clipboardText ~= "" then - local oldText = self._textBuffer - - if self:hasSelection() then - self:deleteSelection(element) - end - - self:insertText(element, clipboardText) - - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(element, self._textBuffer, oldText) - end - end - self:_resetCursorBlink(element, true) - - -- Handle Escape - elseif key == "escape" then - if self:hasSelection() then - self:clearSelection() - else - self:blur(element) - end - self:_resetCursorBlink(element) - end - - self:_saveState(element) -end - --- ==================== --- Mouse Input --- ==================== - ----Convert mouse coordinates to cursor position in text ----@param element Element The parent element ----@param mouseX number -- Mouse X coordinate (absolute) ----@param mouseY number -- Mouse Y coordinate (absolute) ----@return number -- Cursor position (character index) -function TextEditor:mouseToTextPosition(element, mouseX, mouseY) - if not element or not self._textBuffer then - return 0 - end - - local font = self:_getFont(element) - if not font then - return 0 - end - - -- Get content area bounds - local contentX = (element._absoluteX or element.x) + element.padding.left - local contentY = (element._absoluteY or element.y) + element.padding.top - - -- Calculate relative position - local relativeX = mouseX - contentX - local relativeY = mouseY - contentY - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - - -- Single-line handling - if not self.multiline then - if self._textScrollX then - relativeX = relativeX + self._textScrollX - end - - local closestPos = 0 - local closestDist = math.huge - - for i = 0, textLength do - local offset = utf8.offset(text, i + 1) - local beforeText = offset and text:sub(1, offset - 1) or text - local textWidth = font:getWidth(beforeText) - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = i - end - end - - return closestPos - end - - -- Multiline handling - self:_updateTextIfDirty(element) - - -- Split text into lines - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - local lineHeight = font:getHeight() - - -- Get text area width - local textAreaWidth = element.width - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Determine which line was clicked - local clickedLineNum = math.floor(relativeY / lineHeight) + 1 - clickedLineNum = math.max(1, math.min(clickedLineNum, #lines)) - - -- Calculate character offset for lines before clicked line - local charOffset = 0 - for i = 1, clickedLineNum - 1 do - local lineLen = utf8.len(lines[i]) or 0 - charOffset = charOffset + lineLen + 1 - end - - local clickedLine = lines[clickedLineNum] - local lineLen = utf8.len(clickedLine) or 0 - - -- Handle wrapped segments - if self.textWrap and textAreaWidth > 0 then - local wrappedSegments = self:_wrapLine(element, clickedLine, textAreaWidth) - local lineYOffset = (clickedLineNum - 1) * lineHeight - local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1 - segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments)) - - local segment = wrappedSegments[segmentNum] - local segmentText = segment.text - local segmentLen = utf8.len(segmentText) or 0 - local closestPos = segment.startIdx - local closestDist = math.huge - - for i = 0, segmentLen do - local offset = utf8.offset(segmentText, i + 1) - local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText - local textWidth = font:getWidth(beforeText) - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = segment.startIdx + i - end - end - - return charOffset + closestPos - end - - -- No wrapping - local closestPos = 0 - local closestDist = math.huge - - for i = 0, lineLen do - local offset = utf8.offset(clickedLine, i + 1) - local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine - local textWidth = font:getWidth(beforeText) - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = i - end - end - - return charOffset + closestPos -end - ----Handle mouse click on text ----@param element Element The parent element ----@param mouseX number ----@param mouseY number ----@param clickCount number -- 1=single, 2=double, 3=triple -function TextEditor:handleTextClick(element, mouseX, mouseY, clickCount) - if not self._focused then - return - end - - if clickCount == 1 then - local pos = self:mouseToTextPosition(element, mouseX, mouseY) - self:setCursorPosition(element, pos) - self:clearSelection() - self._mouseDownPosition = pos - elseif clickCount == 2 then - self:_selectWordAtPosition(element, self:mouseToTextPosition(element, mouseX, mouseY)) - elseif clickCount >= 3 then - self:selectAll(element) - end - - self:_resetCursorBlink(element) -end - ----Handle mouse drag for text selection ----@param element Element The parent element ----@param mouseX number ----@param mouseY number -function TextEditor:handleTextDrag(element, mouseX, mouseY) - if not self._focused or not element._mouseDownPosition then - return - end - - local currentPos = self:mouseToTextPosition(element, mouseX, mouseY) - - if currentPos ~= element._mouseDownPosition then - self:setSelection(element, element._mouseDownPosition, currentPos) - self._cursorPosition = currentPos - self._textDragOccurred = true - else - self:clearSelection() - end - - self:_resetCursorBlink(element) -end - ----Select word at given position ----@param element Element? The parent element (for scroll updates) ----@param position number -function TextEditor:_selectWordAtPosition(element, position) - if not self._textBuffer then - return - end - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - - if textLength == 0 then - return - end - - -- Helper to get character at position - local function getCharAt(p) - if p < 0 or p >= textLength then - return nil - end - local offset1 = utf8.offset(text, p + 1) - local offset2 = utf8.offset(text, p + 2) - if not offset1 then - return nil - end - if not offset2 then - return text:sub(offset1) - end - return text:sub(offset1, offset2 - 1) - end - - -- Find word boundaries - local startPos = position - local endPos = position - - -- Expand left to start of word - while startPos > 0 do - local char = getCharAt(startPos - 1) - if not char or not char:match("[%w]") then - break - end - startPos = startPos - 1 - end - - -- Expand right to end of word - while endPos < textLength do - local char = getCharAt(endPos) - if not char or not char:match("[%w]") then - break - end - endPos = endPos + 1 - end - - self:setSelection(element, startPos, endPos) - self._cursorPosition = endPos -end - --- ==================== --- Update and Rendering --- ==================== - ----Update cursor blink animation ----@param element Element The parent element ----@param dt number -- Delta time -function TextEditor:update(element, dt) - if not self._focused then - return - end - - -- Update cursor blink - if self._cursorBlinkPaused then - self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt - if self._cursorBlinkPauseTimer >= 0.5 then - self._cursorBlinkPaused = false - self._cursorBlinkPauseTimer = 0 - end - else - self._cursorBlinkTimer = self._cursorBlinkTimer + dt - if self._cursorBlinkTimer >= self.cursorBlinkRate then - self._cursorBlinkTimer = 0 - self._cursorVisible = not self._cursorVisible - end - end - - -- Save state for immediate mode (cursor blink timer changes need to persist) - self:_saveState(element) -end - ----Update element height based on text content (for autoGrow) ----@param element Element The parent element -function TextEditor:updateAutoGrowHeight(element) - if not self.multiline or not self.autoGrow or not element then - return - end - - local font = self:_getFont(element) - if not font then - return - end - - local text = self._textBuffer or "" - local lineHeight = font:getHeight() - - -- Get text area width - local textAreaWidth = element.width - local scaledContentPadding = element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Split text by newlines - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - -- Count total wrapped lines - local totalWrappedLines = 0 - if self.textWrap and textAreaWidth > 0 then - for _, line in ipairs(lines) do - if line == "" then - totalWrappedLines = totalWrappedLines + 1 - else - local wrappedSegments = self:_wrapLine(element, line, textAreaWidth) - totalWrappedLines = totalWrappedLines + #wrappedSegments - end - end - else - totalWrappedLines = #lines - end - - totalWrappedLines = math.max(1, totalWrappedLines) - local newContentHeight = totalWrappedLines * lineHeight - - if element.height ~= newContentHeight then - element.height = newContentHeight - element._borderBoxHeight = element.height + element.padding.top + element.padding.bottom - if element.parent and not element._explicitlyAbsolute then - element.parent:layoutChildren() - end - end -end - --- ==================== --- Helper Methods --- ==================== - ----Get font for text rendering ----@param element Element? The parent element ----@return love.Font? -function TextEditor:_getFont(element) - if not element then - return nil - end - - -- Delegate to Renderer - 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 ----@param element Element? The parent element (needed for focus restoration) -function TextEditor:setState(state, element) - 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 - -- Restore focused element in Context if this element was focused - if self._focused and element then - self._Context.setFocused(element) - end - end -end - ----Save state to StateManager (for immediate mode) ----@param element Element? The parent element -function TextEditor:_saveState(element) - -- Mode-aware guard: in retained mode the TextEditor persists, so state only - -- needs persisting to StateManager in immediate mode. Routed through - -- Context.isImmediateMode (behavior-mode-unification task 11). - if not element or not element._stateId or not self._Context.isImmediateMode() then - return - end - - -- Get current state (may have other sub-modules like eventHandler, scrollManager) - local currentState = self._StateManager.getState(element._stateId) or {} - - -- Update only the textEditor sub-table to match the nested structure - -- used by element:saveState() at endFrame - currentState.textEditor = { - _focused = self._focused, - _textBuffer = self._textBuffer, - _cursorPosition = self._cursorPosition, - _selectionStart = self._selectionStart, - _selectionEnd = self._selectionEnd, - _cursorBlinkTimer = self._cursorBlinkTimer, - _cursorVisible = self._cursorVisible, - _cursorBlinkPaused = self._cursorBlinkPaused, - _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, - } - - self._StateManager.updateState(element._stateId, currentState) -end - -return TextEditor diff --git a/libs/flexlove/modules/TextSanitizer.lua b/libs/flexlove/modules/TextSanitizer.lua deleted file mode 100644 index e7e57d12..00000000 --- a/libs/flexlove/modules/TextSanitizer.lua +++ /dev/null @@ -1,183 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Text sanitization, escaping, and input validation utilities. - --- ErrorHandler is injected via init() for truncation warnings. -local ErrorHandler = nil - ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler } -local function init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - end -end - ---- Sanitize text to prevent security vulnerabilities ---- @param text string? Text to sanitize ---- @param options table? Sanitization options ---- @return string Sanitized text -local function sanitizeText(text, options) - local utf8 = require("utf8") - -- Handle nil or non-string inputs - if text == nil then - return "" - end - if type(text) ~= "string" then - text = tostring(text) - end - - -- Default options - options = options or {} - local maxLength = options.maxLength or 10000 - local allowNewlines = options.allowNewlines ~= false -- default true - local allowTabs = options.allowTabs ~= false -- default true - local stripControls = options.stripControls ~= false -- default true - local trimWhitespace = options.trimWhitespace ~= false -- default true - - -- Remove null bytes (critical security risk) - text = text:gsub("%z", "") - - -- Strip control characters except allowed ones - if stripControls then - local pattern = "[\1-\31\127]" -- All control characters - if allowNewlines and allowTabs then - pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13) - elseif allowNewlines then - pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13) - elseif allowTabs then - pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9) - end - text = text:gsub(pattern, "") - end - - -- Trim leading/trailing whitespace - if trimWhitespace then - text = text:match("^%s*(.-)%s*$") or "" - end - - -- Limit string length (use UTF-8 character count, not byte count) - local charCount = utf8.len(text) - if charCount and charCount > maxLength then - if ErrorHandler then - ErrorHandler:warn("utils", "UTIL_001", { - original = charCount, - truncated = maxLength, - }) - end - -- Truncate to maxLength UTF-8 characters - local bytePos = utf8.offset(text, maxLength + 1) - if bytePos then - text = text:sub(1, bytePos - 1) - end - if ErrorHandler then - ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength)) - end - end - - return text -end - ---- Validate text input against rules ---- @param text string Text to validate ---- @param rules table Validation rules ---- @return boolean, string? Returns true if valid, or false with error message -local function validateTextInput(text, rules) - rules = rules or {} - - -- Check minimum length - if rules.minLength and #text < rules.minLength then - return false, string.format("Text must be at least %d characters", rules.minLength) - end - - -- Check maximum length - if rules.maxLength and #text > rules.maxLength then - return false, string.format("Text must be at most %d characters", rules.maxLength) - end - - -- Check pattern match - if rules.pattern and not text:match(rules.pattern) then - return false, rules.patternError or "Text does not match required pattern" - end - - -- Check character whitelist - if rules.allowedChars then - local pattern = "[^" .. rules.allowedChars .. "]" - if text:match(pattern) then - return false, "Text contains invalid characters" - end - end - - -- Check character blacklist - if rules.forbiddenChars then - local pattern = "[" .. rules.forbiddenChars .. "]" - if text:match(pattern) then - return false, "Text contains forbidden characters" - end - end - - return true, nil -end - ---- Validate text against range/length rules (alias of validateTextInput) ---- @param text string Text to validate ---- @param rules table Validation rules (minLength, maxLength, pattern, etc.) ---- @return boolean, string? Returns true if valid, or false with error message -local function validateTextRange(text, rules) - return validateTextInput(text, rules) -end - ---- Escape HTML special characters ---- @param text string Text to escape ---- @return string Escaped text -local function escapeHtml(text) - if text == nil then - return "" - end - text = tostring(text) - text = text:gsub("&", "&") - text = text:gsub("<", "<") - text = text:gsub(">", ">") - text = text:gsub('"', """) - text = text:gsub("'", "'") - return text -end - ---- Escape Lua pattern special characters ---- @param text string Text to escape ---- @return string Escaped text -local function escapeLuaPattern(text) - if text == nil then - return "" - end - text = tostring(text) - -- Escape all Lua pattern special characters - text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") - return text -end - ---- Strip all non-printable characters from text ---- @param text string Text to clean ---- @return string Cleaned text -local function stripNonPrintable(text) - if text == nil then - return "" - end - text = tostring(text) - -- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13) - text = text:gsub("[^\9\10\13\32-\126]", "") - return text -end - -return { - init = init, - sanitizeText = sanitizeText, - validateTextInput = validateTextInput, - validateTextRange = validateTextRange, - escapeHtml = escapeHtml, - escapeLuaPattern = escapeLuaPattern, - stripNonPrintable = stripNonPrintable, -} diff --git a/libs/flexlove/modules/Theme.lua b/libs/flexlove/modules/Theme.lua deleted file mode 100644 index f6e95bfe..00000000 --- a/libs/flexlove/modules/Theme.lua +++ /dev/null @@ -1,1655 +0,0 @@ ---- Auto-detect the base path where FlexLove is located ----@return string modulePath, string filesystemPath -local function getFlexLoveBasePath() - -- Get debug info to find where this file is loaded from - local info = debug.getinfo(1, "S") - if info and info.source then - local source = info.source - -- Remove leading @ if present - if source:sub(1, 1) == "@" then - source = source:sub(2) - end - - -- Extract the directory path (remove Theme.lua and modules/) - local filesystemPath = source:match("(.*/)") - if filesystemPath then - -- Store the original filesystem path for loading assets - local fsPath = filesystemPath - -- Remove leading ./ if present - fsPath = fsPath:gsub("^%./", "") - -- Remove trailing / - fsPath = fsPath:gsub("/$", "") - -- Remove the flexlove subdirectory to get back to base - fsPath = fsPath:gsub("/modules$", "") - - -- Convert filesystem path to Lua module path - local modulePath = fsPath:gsub("/", ".") - - return modulePath, fsPath - end - end - - -- Fallback: try a common path - return "libs", "libs" -end - --- Store the base paths when module loads -local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() - ---- Validate theme definition structure ----@param definition ThemeDefinition ----@return boolean, string? -- Returns true if valid, or false with error message -local function validateThemeDefinition(definition) - if not definition then - return false, "Theme definition is nil" - end - - if type(definition) ~= "table" then - return false, "Theme definition must be a table" - end - - if not definition.name or type(definition.name) ~= "string" then - return false, "Theme must have a 'name' field (string)" - end - - if definition.components and type(definition.components) ~= "table" then - return false, "Theme 'components' must be a table" - end - - if definition.colors and type(definition.colors) ~= "table" then - return false, "Theme 'colors' must be a table" - end - - if definition.fonts and type(definition.fonts) ~= "table" then - return false, "Theme 'fonts' must be a table" - end - - if definition.scrollbars and type(definition.scrollbars) ~= "table" then - return false, "Theme 'scrollbars' must be a table" - end - - return true, nil -end - ---- Load image data from a file path ----@param imagePath string ----@return love.ImageData -local function loadImageData(imagePath) - if not imagePath then - error("Image path cannot be nil") - end - - local success, result = pcall(function() - return love.image.newImageData(imagePath) - end) - - if not success then - error("Failed to load image data from '" .. imagePath .. "': " .. tostring(result)) - end - - return result -end - ---- Extract all pixels from a specific row ----@param imageData love.ImageData ----@param rowIndex number 0-based row index ----@return table Array of {r, g, b, a} values (0-255 range) -local function getRow(imageData, rowIndex) - if not imageData then - error("ImageData cannot be nil") - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - if rowIndex < 0 or rowIndex >= height then - error(string.format("Row index %d out of bounds (height: %d)", rowIndex, height)) - end - - local pixels = {} - for x = 0, width - 1 do - local r, g, b, a = imageData:getPixel(x, rowIndex) - table.insert(pixels, { - r = math.floor(r * 255 + 0.5), - g = math.floor(g * 255 + 0.5), - b = math.floor(b * 255 + 0.5), - a = math.floor(a * 255 + 0.5), - }) - end - - return pixels -end - ---- Extract all pixels from a specific column ----@param imageData love.ImageData ----@param colIndex number 0-based column index ----@return table Array of {r, g, b, a} values (0-255 range) -local function getColumn(imageData, colIndex) - if not imageData then - error("ImageData cannot be nil") - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - if colIndex < 0 or colIndex >= width then - error(string.format("Column index %d out of bounds (width: %d)", colIndex, width)) - end - - local pixels = {} - for y = 0, height - 1 do - local r, g, b, a = imageData:getPixel(colIndex, y) - table.insert(pixels, { - r = math.floor(r * 255 + 0.5), - g = math.floor(g * 255 + 0.5), - b = math.floor(b * 255 + 0.5), - a = math.floor(a * 255 + 0.5), - }) - end - - return pixels -end - ---- Check if a pixel is black with full alpha (9-patch marker) ----@param r number Red (0-255) ----@param g number Green (0-255) ----@param b number Blue (0-255) ----@param a number Alpha (0-255) ----@return boolean -local function isBlackPixel(r, g, b, a) - return r == 0 and g == 0 and b == 0 and a == 255 -end - ---- Find all continuous runs of black pixels in a pixel array ----@param pixels table Array of {r, g, b, a} pixel values ----@return table Array of {start, end} pairs (1-based indices, inclusive) -local function findBlackPixelRuns(pixels) - local runs = {} - local inRun = false - local runStart = nil - - for i = 1, #pixels do - local pixel = pixels[i] - local isBlack = isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a) - - if isBlack and not inRun then - -- Start of a new run - inRun = true - runStart = i - elseif not isBlack and inRun then - -- End of current run - table.insert(runs, { start = runStart, ["end"] = i - 1 }) - inRun = false - runStart = nil - end - end - - -- Handle case where run extends to end of array - if inRun then - table.insert(runs, { start = runStart, ["end"] = #pixels }) - end - - return runs -end - ---- Parse a 9-patch PNG image to extract stretch regions and content padding ----@param imagePath string Path to the 9-patch image file ----@return table|nil, string|nil Returns {insets, stretchX, stretchY} or nil, error message -local function parseNinePatch(imagePath) - if not imagePath then - return nil, "Image path cannot be nil" - end - - local success, imageData = pcall(function() - return loadImageData(imagePath) - end) - - if not success then - return nil, "Failed to load image data: " .. tostring(imageData) - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - -- Validate minimum size (must be at least 3x3 with 1px border) - if width < 3 or height < 3 then - return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height) - end - - -- Extract border pixels (0-based indexing, but we convert to 1-based for processing) - local topBorder = getRow(imageData, 0) - local leftBorder = getColumn(imageData, 0) - local bottomBorder = getRow(imageData, height - 1) - local rightBorder = getColumn(imageData, width - 1) - - -- Remove corner pixels from borders (they're not part of the stretch/content markers) - -- Top and bottom borders: remove first and last pixel - local topStretchPixels = {} - local bottomContentPixels = {} - for i = 2, #topBorder - 1 do - table.insert(topStretchPixels, topBorder[i]) - end - for i = 2, #bottomBorder - 1 do - table.insert(bottomContentPixels, bottomBorder[i]) - end - - -- Left and right borders: remove first and last pixel - local leftStretchPixels = {} - local rightContentPixels = {} - for i = 2, #leftBorder - 1 do - table.insert(leftStretchPixels, leftBorder[i]) - end - for i = 2, #rightBorder - 1 do - table.insert(rightContentPixels, rightBorder[i]) - end - - -- Find stretch regions (top and left borders) - local stretchX = findBlackPixelRuns(topStretchPixels) - local stretchY = findBlackPixelRuns(leftStretchPixels) - - -- Find content padding regions (bottom and right borders) - local contentX = findBlackPixelRuns(bottomContentPixels) - local contentY = findBlackPixelRuns(rightContentPixels) - - -- Validate that we have at least one stretch region - if #stretchX == 0 or #stretchY == 0 then - return nil, "No stretch regions found (top or left border has no black pixels)" - end - - -- Calculate stretch insets from stretch regions (top/left guides) - -- Use the first stretch region's start and last stretch region's end - local firstStretchX = stretchX[1] - local lastStretchX = stretchX[#stretchX] - local firstStretchY = stretchY[1] - local lastStretchY = stretchY[#stretchY] - - -- Stretch insets define the 9-patch regions - local stretchLeft = firstStretchX.start - local stretchRight = #topStretchPixels - lastStretchX["end"] - local stretchTop = firstStretchY.start - local stretchBottom = #leftStretchPixels - lastStretchY["end"] - - -- Calculate content padding from content guides (bottom/right guides) - -- If content padding is defined, use it; otherwise use stretch regions - local contentLeft, contentRight, contentTop, contentBottom - - if #contentX > 0 then - contentLeft = contentX[1].start - contentRight = #topStretchPixels - contentX[#contentX]["end"] - else - contentLeft = stretchLeft - contentRight = stretchRight - end - - if #contentY > 0 then - contentTop = contentY[1].start - contentBottom = #leftStretchPixels - contentY[#contentY]["end"] - else - contentTop = stretchTop - contentBottom = stretchBottom - end - - return { - insets = { - left = stretchLeft, - top = stretchTop, - right = stretchRight, - bottom = stretchBottom, - }, - contentPadding = { - left = contentLeft, - top = contentTop, - right = contentRight, - bottom = contentBottom, - }, - stretchX = stretchX, - stretchY = stretchY, - } -end - ----@class Theme -local Theme = {} -Theme.__index = Theme - ---- Initialize module with shared dependencies ----@param deps table Dependencies {ErrorHandler, Color, utils} -function Theme.init(deps) - if type(deps) == "table" then - Theme._ErrorHandler = deps.ErrorHandler - Theme._Color = deps.Color - Theme._utils = deps.utils - end -end - --- Global theme registry -local themes = {} -local activeTheme = nil - ---- Create reusable design systems with consistent styling, 9-patch assets, and component states ---- Use this to build professional-looking UIs with minimal per-element configuration ----@param definition ThemeDefinition Theme definition table ----@return Theme theme The new theme instance -function Theme.new(definition) - -- Validate input type first - if type(definition) ~= "table" then - Theme._ErrorHandler:warn("Theme", "THM_001", { - error = "Theme definition must be a table, got " .. type(definition), - }) - return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} }) - end - - -- Validate theme definition - local valid, err = validateThemeDefinition(definition) - if not valid then - Theme._ErrorHandler:warn("Theme", "THM_001", { - error = tostring(err), - }) - return Theme.new({ name = "fallback", components = {}, colors = {}, fonts = {} }) - end - - local self = setmetatable({}, Theme) - self.name = definition.name - - -- Load global atlas if it's a string path - if definition.atlas then - if type(definition.atlas) == "string" then - local resolvedPath = Theme._utils.resolveImagePath(definition.atlas) - local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath) - if image then - self.atlas = image - self.atlasData = imageData - else - Theme._ErrorHandler:warn("Theme", "RES_001", { - theme = definition.name, - path = resolvedPath, - error = loaderr, - }) - end - else - self.atlas = definition.atlas - end - end - - self.components = definition.components or {} - self.scrollbars = definition.scrollbars or {} - self.colors = definition.colors or {} - self.fonts = definition.fonts or {} - self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil - - -- Helper function to strip 1-pixel guide border from 9-patch ImageData - ---@param sourceImageData love.ImageData - ---@return love.ImageData -- New ImageData without guide border - local function stripNinePatchBorder(sourceImageData) - local srcWidth = sourceImageData:getWidth() - local srcHeight = sourceImageData:getHeight() - - -- Content dimensions (excluding 1px border on all sides) - local contentWidth = srcWidth - 2 - local contentHeight = srcHeight - 2 - - if contentWidth <= 0 or contentHeight <= 0 then - Theme._ErrorHandler:warn("Theme", "RES_002", { - width = srcWidth, - height = srcHeight, - reason = "Image must be larger than 2x2 pixels to have content after stripping 1px border", - }) - return nil - end - - -- Create new ImageData for content only - local strippedImageData = love.image.newImageData(contentWidth, contentHeight) - - -- Copy pixels from source (1,1) to (width-2, height-2) - for y = 0, contentHeight - 1 do - for x = 0, contentWidth - 1 do - local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1) - strippedImageData:setPixel(x, y, r, g, b, a) - end - end - - return strippedImageData - end - - -- Helper function to load atlas with 9-patch support - local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) - ---@diagnostic disable-next-line - local resolvedPath = Theme._utils.resolveImagePath(atlasPath) - ---@diagnostic disable-next-line - local is9Patch = not comp.insets and atlasPath:match("%.9%.png$") - - if is9Patch then - local parseResult, parseErr = parseNinePatch(resolvedPath) - if parseResult then - comp.insets = parseResult.insets - comp._ninePatchData = parseResult - else - Theme._ErrorHandler:warn("Theme", "RES_003", { - context = errorContext, - path = resolvedPath, - error = tostring(parseErr), - }) - end - end - - local image, imageData, loaderr = Theme._utils.safeLoadImage(resolvedPath) - if image then - -- Strip guide border for 9-patch images - if is9Patch and imageData then - local strippedImageData = stripNinePatchBorder(imageData) - local strippedImage = love.graphics.newImage(strippedImageData) - comp._loadedAtlas = strippedImage - comp._loadedAtlasData = strippedImageData - else - comp._loadedAtlas = image - comp._loadedAtlasData = imageData - end - else - Theme._ErrorHandler:warn("Theme", "RES_001", { - context = errorContext, - path = resolvedPath, - error = tostring(loaderr), - }) - end - end - - -- Helper function to create regions from insets - local function createRegionsFromInsets(comp, fallbackAtlas) - local atlasImage = comp._loadedAtlas or fallbackAtlas - if not atlasImage or type(atlasImage) == "string" then - return - end - - local imgWidth, imgHeight = atlasImage:getDimensions() - local left = comp.insets.left or 0 - local top = comp.insets.top or 0 - local right = comp.insets.right or 0 - local bottom = comp.insets.bottom or 0 - - -- No offsets needed - guide border has been stripped for 9-patch images - local centerWidth = imgWidth - left - right - local centerHeight = imgHeight - top - bottom - - comp.regions = { - topLeft = { x = 0, y = 0, w = left, h = top }, - topCenter = { x = left, y = 0, w = centerWidth, h = top }, - topRight = { x = left + centerWidth, y = 0, w = right, h = top }, - middleLeft = { x = 0, y = top, w = left, h = centerHeight }, - middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight }, - middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight }, - bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom }, - bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom }, - bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom }, - } - end - - -- Load component-specific atlases and process 9-patch definitions - for componentName, component in pairs(self.components) do - if component.atlas then - if type(component.atlas) == "string" then - loadAtlasWithNinePatch(component, component.atlas, "for component '" .. componentName .. "'") - else - -- Direct Image object (no ImageData available - scaleCorners won't work) - component._loadedAtlas = component.atlas - end - end - - if component.insets then - createRegionsFromInsets(component, self.atlas) - end - - if component.states then - for stateName, stateComponent in pairs(component.states) do - if stateComponent.atlas then - if type(stateComponent.atlas) == "string" then - loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for state '" .. stateName .. "'") - else - -- Direct Image object (no ImageData available - scaleCorners won't work) - stateComponent._loadedAtlas = stateComponent.atlas - end - end - - if stateComponent.insets then - createRegionsFromInsets(stateComponent, component._loadedAtlas or self.atlas) - end - end - end - end - - -- Load scrollbar-specific atlases and process 9-patch definitions - -- Scrollbars can have 'bar' and 'frame' subcomponents - for scrollbarName, scrollbarDef in pairs(self.scrollbars) do - -- Handle scrollbar definitions with bar/frame subcomponents - if scrollbarDef.bar or scrollbarDef.frame then - -- Process 'bar' subcomponent - if scrollbarDef.bar then - if type(scrollbarDef.bar) == "string" then - -- Convert string path to ThemeComponent structure - local barComponent = { atlas = scrollbarDef.bar } - -- Copy knobOffset from parent scrollbarDef if it exists - if scrollbarDef.knobOffset then - barComponent.knobOffset = scrollbarDef.knobOffset - end - loadAtlasWithNinePatch(barComponent, scrollbarDef.bar, "for scrollbar '" .. scrollbarName .. ".bar'") - if barComponent.insets then - createRegionsFromInsets(barComponent, barComponent._loadedAtlas or self.atlas) - end - scrollbarDef.bar = barComponent - elseif type(scrollbarDef.bar) == "table" then - -- Already a ThemeComponent structure, process it - -- Copy knobOffset from parent if bar component doesn't have one - if scrollbarDef.knobOffset and not scrollbarDef.bar.knobOffset then - scrollbarDef.bar.knobOffset = scrollbarDef.knobOffset - end - if scrollbarDef.bar.atlas and type(scrollbarDef.bar.atlas) == "string" then - loadAtlasWithNinePatch( - scrollbarDef.bar, - scrollbarDef.bar.atlas, - "for scrollbar '" .. scrollbarName .. ".bar'" - ) - end - if scrollbarDef.bar.insets then - createRegionsFromInsets(scrollbarDef.bar, scrollbarDef.bar._loadedAtlas or self.atlas) - end - end - end - - -- Process 'frame' subcomponent - if scrollbarDef.frame then - if type(scrollbarDef.frame) == "string" then - -- Convert string path to ThemeComponent structure - local frameComponent = { atlas = scrollbarDef.frame } - loadAtlasWithNinePatch(frameComponent, scrollbarDef.frame, "for scrollbar '" .. scrollbarName .. ".frame'") - if frameComponent.insets then - createRegionsFromInsets(frameComponent, frameComponent._loadedAtlas or self.atlas) - end - scrollbarDef.frame = frameComponent - elseif type(scrollbarDef.frame) == "table" then - -- Already a ThemeComponent structure, process it - if scrollbarDef.frame.atlas and type(scrollbarDef.frame.atlas) == "string" then - loadAtlasWithNinePatch( - scrollbarDef.frame, - scrollbarDef.frame.atlas, - "for scrollbar '" .. scrollbarName .. ".frame'" - ) - end - if scrollbarDef.frame.insets then - createRegionsFromInsets(scrollbarDef.frame, scrollbarDef.frame._loadedAtlas or self.atlas) - end - end - end - else - -- Treat as a single ThemeComponent (no bar/frame split) - if scrollbarDef.atlas then - if type(scrollbarDef.atlas) == "string" then - loadAtlasWithNinePatch(scrollbarDef, scrollbarDef.atlas, "for scrollbar '" .. scrollbarName .. "'") - else - scrollbarDef._loadedAtlas = scrollbarDef.atlas - end - end - - if scrollbarDef.insets then - createRegionsFromInsets(scrollbarDef, self.atlas) - end - - if scrollbarDef.states then - for stateName, stateComponent in pairs(scrollbarDef.states) do - if stateComponent.atlas then - if type(stateComponent.atlas) == "string" then - loadAtlasWithNinePatch( - stateComponent, - stateComponent.atlas, - "for scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "'" - ) - else - stateComponent._loadedAtlas = stateComponent.atlas - end - end - - if stateComponent.insets then - createRegionsFromInsets(stateComponent, scrollbarDef._loadedAtlas or self.atlas) - end - end - end - end - end - - return self -end - ---- Import a theme definition from a file to enable hot-reloading and modular design systems ---- Use this to load bundled or user-created themes dynamically ----@param path string Path to theme definition file (e.g., "space" or "mytheme") ----@return Theme? theme The loaded theme, or nil on error -function Theme.load(path) - local definition - local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path - - local success, result = pcall(function() - return require(themePath) - end) - if success then - definition = result - else - success, result = pcall(function() - return require(path) - end) - if success then - definition = result - else - Theme._ErrorHandler:warn("Theme", "RES_004", { - theme = path, - tried = themePath, - error = tostring(result), - fallback = "nil (no theme loaded)", - }) - return nil - end - end - - local theme = Theme.new(definition) - themes[theme.name] = theme - themes[path] = theme - - return theme -end - ---- Switch the global theme to instantly restyle all themed UI elements ---- Use this to implement light/dark mode toggles or user-selectable skins ----@param themeOrName Theme|string Theme instance or theme name to activate -function Theme.setActive(themeOrName) - if type(themeOrName) == "string" then - -- Try to load if not already loaded - if not themes[themeOrName] then - Theme.load(themeOrName) - end - activeTheme = themes[themeOrName] - else - activeTheme = themeOrName - end - - if not activeTheme then - Theme._ErrorHandler:warn("Theme", "THM_002", { - theme = tostring(themeOrName), - reason = "Theme not found or not loaded", - fallback = "current theme unchanged", - }) - -- Keep current activeTheme unchanged (fallback behavior) - end -end - ---- Access the current theme to query colors, fonts, or create theme-aware components ---- Use this to build UI that adapts to the active design system ----@return Theme? theme The active theme, or nil if none is active -function Theme.getActive() - return activeTheme -end - ---- Retrieve pre-configured visual styles for UI components to maintain consistency ---- Use this to apply theme definitions to custom elements ----@param componentName string Name of the component (e.g., "button", "panel") ----@param state string? Optional state (e.g., "hover", "pressed", "disabled") ----@return ThemeComponent? component Returns component or nil if not found -function Theme.getComponent(componentName, state) - if not activeTheme then - return nil - end - - local component = activeTheme.components[componentName] - if not component then - return nil - end - - -- Check for state-specific override - if state and component.states and component.states[state] then - return component.states[state] - end - - return component -end - ---- Get the first (default) scrollbar from the active theme ---- Returns the first scrollbar component in insertion order ----@return ThemeComponent? scrollbar Returns first scrollbar component or nil if no scrollbars defined -function Theme.getDefaultScrollbar() - if not activeTheme or not activeTheme.scrollbars then - return nil - end - - local _, scrollbar = next(activeTheme.scrollbars) - return scrollbar -end - ---- Retrieve themed scrollbar components for consistent scrollbar styling ---- Use this to apply theme-based scrollbar appearance to scrollable elements ----@param scrollbarName string? Name of the scrollbar style (e.g., "v1", "v2"). If nil, returns default (first) scrollbar ----@param state string? Optional state name (e.g., "hover", "pressed") - currently unused for scrollbars ----@return ThemeComponent? scrollbar Returns scrollbar component or nil if not found -function Theme.getScrollbar(scrollbarName, state) - if not activeTheme or not activeTheme.scrollbars then - return nil - end - - -- If no scrollbarName specified, return default (first) scrollbar - if not scrollbarName then - return Theme.getDefaultScrollbar() - end - - local scrollbar = activeTheme.scrollbars[scrollbarName] - if not scrollbar then - return nil - end - - -- Check for state-specific override (if scrollbar supports states in the future) - if state and scrollbar.states and scrollbar.states[state] then - return scrollbar.states[state] - end - - return scrollbar -end - ---- Access theme-defined fonts for consistent typography across your UI ---- Use this to load fonts specified in your theme definition ----@param fontName string Name of the font family (e.g., "default", "heading") ----@return string? fontPath Returns font path or nil if not found -function Theme.getFont(fontName) - if not activeTheme then - return nil - end - - return activeTheme.fonts and activeTheme.fonts[fontName] -end - ---- Retrieve semantic colors from the theme palette for consistent brand identity ---- Use this instead of hardcoding colors to support themeing and color scheme switches ----@param colorName string Name of the color (e.g., "primary", "secondary") ----@return Color? color Returns Color instance or nil if not found -function Theme.getColor(colorName) - if not activeTheme then - return nil - end - - return activeTheme.colors and activeTheme.colors[colorName] -end - ---- Check if a theme is currently active ----@return boolean active Returns true if a theme is active -function Theme.hasActive() - return activeTheme ~= nil -end - ---- Get all registered theme names ----@return string[] themeNames Array of theme names -function Theme.getRegisteredThemes() - local themeNames = {} - for name, _ in pairs(themes) do - table.insert(themeNames, name) - end - return themeNames -end - ---- Get all available color names from the active theme ----@return string[]? colorNames Array of color names, or nil if no theme active -function Theme.getColorNames() - if not activeTheme or not activeTheme.colors then - return nil - end - - local colorNames = {} - for name, _ in pairs(activeTheme.colors) do - table.insert(colorNames, name) - end - return colorNames -end - ---- Get all colors from the active theme ----@return table? colors Table of all colors, or nil if no theme active -function Theme.getAllColors() - if not activeTheme then - return nil - end - - return activeTheme.colors -end - ---- Safely get theme colors with guaranteed fallbacks to prevent missing color errors ---- Use this when you need a color value no matter what ----@param colorName string Name of the color to retrieve ----@param fallback Color? Fallback color if not found (default: white) ----@return Color color The color or fallback (guaranteed non-nil) -function Theme.getColorOrDefault(colorName, fallback) - local color = Theme.getColor(colorName) - if color then - return color - end - - return fallback or Theme._Color.new(1, 1, 1, 1) -end - ---- Get a theme by name ----@param themeName string Name of the theme ----@return Theme? theme Returns theme or nil if not found -function Theme.get(themeName) - return themes[themeName] -end - --------------------------------------------------------------------------------- --- ThemeManager: Instance-level theme state management --------------------------------------------------------------------------------- - ----@class ThemeManager -local ThemeManager = {} -ThemeManager.__index = ThemeManager - ----Create a new ThemeManager instance ----@param config table Configuration options {theme: string?, themeComponent: string?, disabled: boolean?, active: boolean?, disableHighlight: boolean?, themeStateLock: boolean|string?, themeComponentDisabledStates: string[]?, scaleCorners: number?, scalingAlgorithm: string?} ----@return ThemeManager manager The new ThemeManager instance -function ThemeManager.new(config) - local self = setmetatable({}, ThemeManager) - - self.theme = config.theme - self.themeComponent = config.themeComponent - self.disabled = config.disabled or false - self.active = config.active or false - self.disableHighlight = config.disableHighlight - self.themeStateLock = config.themeStateLock or false - self.scaleCorners = config.scaleCorners - self.scalingAlgorithm = config.scalingAlgorithm - - -- Normalize themeComponentDisabledStates to a lookup set for O(1) checks - self.themeComponentDisabledStates = {} - if config.themeComponentDisabledStates then - for _, state in ipairs(config.themeComponentDisabledStates) do - if type(state) == "string" then - self.themeComponentDisabledStates[state] = true - end - end - end - - -- Set initial state based on themeStateLock - if self.themeStateLock == true or self.themeStateLock == "default" then - self._themeState = "normal" - elseif type(self.themeStateLock) == "string" then - self._themeState = self.themeStateLock - else - self._themeState = "normal" - end - - return self -end - ----Update the theme state based on element interaction state ----@param isHovered boolean Whether element is hovered ----@param isPressed boolean Whether element is pressed ----@param isFocused boolean Whether element is focused (keyboard focus) ----@param isDisabled boolean Whether element is disabled ----@return string state The new theme state ("normal", "hover", "pressed", "active", "disabled") -function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) - -- If themeStateLock is set (and not false), use the locked state - if self.themeStateLock ~= false and self.themeStateLock ~= nil then - local lockedState - - if self.themeStateLock == true or self.themeStateLock == "default" then - -- true or "default" means lock to "normal" (base state) - lockedState = "normal" - elseif type(self.themeStateLock) == "string" then - -- String means lock to specific state - lockedState = self.themeStateLock - - -- Validate the locked state exists in the theme component (will be done during initialization) - -- For now, just use the string value - else - -- Invalid themeStateLock value, fall back to normal behavior - lockedState = nil - end - - if lockedState then - self._themeState = lockedState - return lockedState - end - end - - -- Normal behavior: calculate state based on interaction - -- Keyboard focus reuses the hover state so themes only need one visual variant. - -- Priority: disabled > active > pressed > hover/focus > normal - -- If a state is in themeComponentDisabledStates, fall through to the next lower-priority state. - local candidates = { - { state = "disabled", condition = isDisabled or self.disabled }, - { state = "active", condition = self.active }, - { state = "pressed", condition = isPressed }, - { state = "hover", condition = isHovered or isFocused }, - } - - local newState = "normal" - for _, candidate in ipairs(candidates) do - if candidate.condition and not self.themeComponentDisabledStates[candidate.state] then - newState = candidate.state - break - end - end - - self._themeState = newState - return newState -end - ----Get the current theme state ----@return string state The current theme state -function ThemeManager:getState() - return self._themeState -end - ----Set the theme state explicitly ----@param state string The theme state to set ("normal", "hover", "pressed", "active", "disabled") -function ThemeManager:setState(state) - if type(state) ~= "string" then - return - end - self._themeState = state -end - ----Check if a theme component is set ----@return boolean hasComponent True if a theme component is set -function ThemeManager:hasThemeComponent() - return self.themeComponent ~= nil -end - ----Get the theme (either instance-specific or active theme) ----@return Theme? theme The theme instance, or nil if not found -function ThemeManager:getTheme() - if self.theme then - return Theme.get(self.theme) - end - return Theme.getActive() -end - ----Get the base theme component ----@return ThemeComponent? component The theme component, or nil if not found -function ThemeManager:getComponent() - if not self.themeComponent then - return nil - end - - local themeToUse = self:getTheme() - if not themeToUse or not themeToUse.components or type(themeToUse.components) ~= "table" then - return nil - end - - if not themeToUse.components[self.themeComponent] then - return nil - end - - return themeToUse.components[self.themeComponent] -end - ----Get the theme component for the current state ----@return ThemeComponent? component The state-specific component, or base component, or nil -function ThemeManager:getStateComponent() - local component = self:getComponent() - if not component then - return nil - end - - local state = self._themeState - if - state - and state ~= "normal" - and component.states - and type(component.states) == "table" - and component.states[state] - then - return component.states[state] - end - - return component -end - ----Get a scrollbar component from the theme ----@param scrollbarName string? The scrollbar style name (e.g., "v1", "v2"). If nil, returns default (first) scrollbar ----@return ThemeComponent? scrollbar The scrollbar component, or nil if not found -function ThemeManager:getScrollbarComponent(scrollbarName) - local themeToUse = self:getTheme() - if not themeToUse or not themeToUse.scrollbars or type(themeToUse.scrollbars) ~= "table" then - return nil - end - - if not scrollbarName then - local _, scrollbar = next(themeToUse.scrollbars) - return scrollbar - end - - return themeToUse.scrollbars[scrollbarName] -end - ----Get a style property from the current state component ----@param property string The property name ----@return any? value The property value, or nil if not found -function ThemeManager:getStyle(property) - if type(property) ~= "string" then - return nil - end - - local stateComponent = self:getStateComponent() - if not stateComponent or type(stateComponent) ~= "table" then - return nil - end - - return stateComponent[property] -end - ----Get scaled content padding based on border box dimensions ----@param borderBoxWidth number The border box width ----@param borderBoxHeight number The border box height ----@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding -function ThemeManager:_getScaledContentPaddingForComponent(component, borderBoxWidth, borderBoxHeight) - if not component or not component._ninePatchData or not component._ninePatchData.contentPadding then - return nil - end - - local contentPadding = component._ninePatchData.contentPadding - local themeToUse = self:getTheme() - local atlasImage = component._loadedAtlas or (themeToUse and themeToUse.atlas) - - if atlasImage and type(atlasImage) ~= "string" then - local originalWidth, originalHeight = atlasImage:getDimensions() - - local insets = component.insets - if insets and type(insets) == "table" then - local cornerScale = self.scaleCorners - if cornerScale == nil then - cornerScale = component.scaleCorners - end - if type(cornerScale) ~= "number" or cornerScale <= 0 then - cornerScale = 1 - end - - local function mapDistanceFromStart(sourceDistance, sourceSize, targetSize, sourceStartInset, sourceEndInset) - local sourceStart = sourceStartInset or 0 - local sourceEnd = sourceEndInset or 0 - - local sourceCenter = math.max(0, sourceSize - sourceStart - sourceEnd) - local targetStart = sourceStart * cornerScale - local targetEnd = sourceEnd * cornerScale - local targetCenter = math.max(0, targetSize - targetStart - targetEnd) - - if sourceDistance <= sourceStart then - return sourceDistance * cornerScale - end - - if sourceDistance >= (sourceSize - sourceEnd) then - local distanceFromEnd = sourceSize - sourceDistance - return targetSize - (distanceFromEnd * cornerScale) - end - - if sourceCenter <= 0 then - return targetStart - end - - local t = (sourceDistance - sourceStart) / sourceCenter - return targetStart + (t * targetCenter) - end - - local left = mapDistanceFromStart(contentPadding.left, originalWidth, borderBoxWidth, insets.left, insets.right) - local rightBoundary = mapDistanceFromStart( - originalWidth - contentPadding.right, - originalWidth, - borderBoxWidth, - insets.left, - insets.right - ) - local right = borderBoxWidth - rightBoundary - - local top = mapDistanceFromStart(contentPadding.top, originalHeight, borderBoxHeight, insets.top, insets.bottom) - local bottomBoundary = mapDistanceFromStart( - originalHeight - contentPadding.bottom, - originalHeight, - borderBoxHeight, - insets.top, - insets.bottom - ) - local bottom = borderBoxHeight - bottomBoundary - - return { - left = math.max(0, left), - top = math.max(0, top), - right = math.max(0, right), - bottom = math.max(0, bottom), - } - end - - local scaleX = borderBoxWidth / originalWidth - local scaleY = borderBoxHeight / originalHeight - return { - left = contentPadding.left * scaleX, - top = contentPadding.top * scaleY, - right = contentPadding.right * scaleX, - bottom = contentPadding.bottom * scaleY, - } - end - - return nil -end - ----Get scaled content padding for a specific theme state ----@param state string The theme state to resolve (e.g. "normal", "pressed") ----@param borderBoxWidth number The border box width ----@param borderBoxHeight number The border box height ----@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding -function ThemeManager:_getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) - if not self.themeComponent then - return nil - end - - local themeToUse = self:getTheme() - if not themeToUse or not themeToUse.components[self.themeComponent] then - return nil - end - - local component = themeToUse.components[self.themeComponent] - - local stateToUse = state or "normal" - if stateToUse ~= "normal" and component.states and component.states[stateToUse] then - component = component.states[stateToUse] - end - - return self:_getScaledContentPaddingForComponent(component, borderBoxWidth, borderBoxHeight) -end - ----@param state string The theme state to resolve (e.g. "normal", "pressed") ----@param borderBoxWidth number The border box width ----@param borderBoxHeight number The border box height ----@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding -function ThemeManager:getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) - Theme._ErrorHandler:warnDeprecated("Theme", "getScaledContentPaddingForState", "getScaledContentPadding") - return self:getScaledContentPadding(borderBoxWidth, borderBoxHeight) -end - ----Get scaled content padding based on current theme state and border box dimensions ----@param borderBoxWidth number The border box width ----@param borderBoxHeight number The border box height ----@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding -function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) - local state = self._themeState or "normal" - return self:_getScaledContentPaddingForState(state, borderBoxWidth, borderBoxHeight) -end - ----Get content auto-sizing multiplier from theme or component ----@return table? multiplier Table with {width: number?, height: number?}, or nil if not defined -function ThemeManager:getContentAutoSizingMultiplier() - if not self.themeComponent then - return nil - end - - local themeToUse = self:getTheme() - if not themeToUse then - return nil - end - - if self.themeComponent and themeToUse.components and type(themeToUse.components) == "table" then - local component = themeToUse.components[self.themeComponent] - if component and component.contentAutoSizingMultiplier then - return component.contentAutoSizingMultiplier - elseif themeToUse.contentAutoSizingMultiplier then - return themeToUse.contentAutoSizingMultiplier - end - end - - if themeToUse.contentAutoSizingMultiplier then - return themeToUse.contentAutoSizingMultiplier - end - - return nil -end - ----Get the default font family path from the theme ----@return string? fontPath The default font path, or nil if not defined -function ThemeManager:getDefaultFontFamily() - local themeToUse = self:getTheme() - if themeToUse and themeToUse.fonts and type(themeToUse.fonts) == "table" and themeToUse.fonts["default"] then - return themeToUse.fonts["default"] - end - return nil -end - ----Set the theme and component for this ThemeManager ----@param themeName string? The theme name to use (nil to use active theme) ----@param componentName string? The component name to use -function ThemeManager:setTheme(themeName, componentName) - self.theme = themeName - self.themeComponent = componentName -end - ----Validate themeStateLock and warn if invalid ----@return boolean isValid True if themeStateLock is valid or false/nil -function ThemeManager:validateThemeStateLock() - -- false or nil is always valid (no lock) - if not self.themeStateLock or self.themeStateLock == false then - return true - end - - -- true is always valid (lock to normal) - if self.themeStateLock == true then - return true - end - - -- String value needs validation - if type(self.themeStateLock) == "string" then - -- "default" is always valid (lock to normal/base state) - if self.themeStateLock == "default" then - return true - end - - local component = self:getComponent() - - -- If no component, warn that themeStateLock has no effect - if not component then - if self.themeComponent then - Theme._ErrorHandler:warn("Theme", "THM_007", { - themeComponent = self.themeComponent, - reason = "themeStateLock has no effect without a valid theme component", - }) - end - self.themeStateLock = false - return false - end - - -- Check if component has any states at all - if not component.states or type(component.states) ~= "table" or next(component.states) == nil then - Theme._ErrorHandler:warn("Theme", "THM_008", { - themeComponent = self.themeComponent, - reason = "Theme component has no state variants, themeStateLock has no effect", - }) - self.themeStateLock = false - return false - end - - -- Check if the specified state exists - if not component.states[self.themeStateLock] then - -- Warn and fall back to false (no lock) - Theme._ErrorHandler:warn("Theme", "THM_009", { - themeComponent = self.themeComponent, - requestedState = self.themeStateLock, - availableStates = table.concat(self:_getAvailableStates(component), ", "), - fallback = "themeStateLock disabled (using dynamic state)", - }) - self.themeStateLock = false - return false - end - - return true - end - - -- Invalid type for themeStateLock - Theme._ErrorHandler:warn("Theme", "THM_010", { - themeStateLockType = type(self.themeStateLock), - reason = "themeStateLock must be boolean or string", - fallback = "themeStateLock disabled", - }) - self.themeStateLock = false - return false -end - ----Get available state names for a component ----@param component ThemeComponent The component to check ----@return table stateNames Array of state names -function ThemeManager:_getAvailableStates(component) - local states = {} - if component and component.states and type(component.states) == "table" then - for stateName, _ in pairs(component.states) do - table.insert(states, stateName) - end - end - return states -end - -Theme.Manager = ThemeManager - ---- Check theme definitions for correctness before use to catch configuration errors early ---- Use this during development to verify custom themes are properly structured ----@param theme table? The theme to validate ----@param options table? Optional validation options {strict: boolean} ----@return boolean valid, table errors List of validation errors -function Theme.validateTheme(theme, options) - local errors = {} - options = options or {} - - -- Basic structure validation - if theme == nil then - table.insert(errors, "Theme is nil") - return false, errors - end - - if type(theme) ~= "table" then - table.insert(errors, "Theme must be a table") - return false, errors - end - - -- Name validation (only required field) - if not theme.name then - table.insert(errors, "Theme must have a 'name' field") - elseif type(theme.name) ~= "string" then - table.insert(errors, "Theme 'name' must be a string") - elseif theme.name == "" then - table.insert(errors, "Theme 'name' cannot be empty") - end - - -- Colors validation (optional, but if present must be valid) - if theme.colors ~= nil then - if type(theme.colors) ~= "table" then - table.insert(errors, "Theme 'colors' must be a table") - else - for colorName, colorValue in pairs(theme.colors) do - if type(colorName) ~= "string" then - table.insert(errors, "Color name must be a string, got " .. type(colorName)) - else - -- Accept Color objects, hex strings, or named colors - local colorType = type(colorValue) - if colorType == "table" then - -- Assume it's a Color object if it has r,g,b fields - if not (colorValue.r and colorValue.g and colorValue.b) then - table.insert(errors, "Color '" .. colorName .. "' is not a valid Color object") - end - elseif colorType == "string" then - -- Validate color string - local isValid, err = Theme._Color.validateColor(colorValue) - if not isValid then - table.insert(errors, "Color '" .. colorName .. "': " .. err) - end - else - table.insert(errors, "Color '" .. colorName .. "' must be a Color object or string") - end - end - end - end - end - - -- Fonts validation (optional) - if theme.fonts ~= nil then - if type(theme.fonts) ~= "table" then - table.insert(errors, "Theme 'fonts' must be a table") - else - for fontName, fontPath in pairs(theme.fonts) do - if type(fontName) ~= "string" then - table.insert(errors, "Font name must be a string, got " .. type(fontName)) - elseif type(fontPath) ~= "string" then - table.insert(errors, "Font '" .. fontName .. "' path must be a string") - end - end - end - end - - -- Components validation (optional) - if theme.components ~= nil then - if type(theme.components) ~= "table" then - table.insert(errors, "Theme 'components' must be a table") - else - for componentName, component in pairs(theme.components) do - if type(component) == "table" then - -- Validate atlas if present - if component.atlas ~= nil and type(component.atlas) ~= "string" then - table.insert(errors, "Component '" .. componentName .. "' atlas must be a string") - end - - -- Validate insets if present - if component.insets ~= nil then - if type(component.insets) ~= "table" then - table.insert(errors, "Component '" .. componentName .. "' insets must be a table") - else - -- If insets are provided, all 4 sides must be present - for _, side in ipairs({ "left", "top", "right", "bottom" }) do - if component.insets[side] == nil then - table.insert(errors, "Component '" .. componentName .. "' insets must have '" .. side .. "' field") - elseif type(component.insets[side]) ~= "number" then - table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be a number") - elseif component.insets[side] < 0 then - table.insert(errors, "Component '" .. componentName .. "' insets." .. side .. " must be non-negative") - end - end - end - end - - -- Validate states if present - if component.states ~= nil then - if type(component.states) ~= "table" then - table.insert(errors, "Component '" .. componentName .. "' states must be a table") - else - for stateName, stateComponent in pairs(component.states) do - if type(stateComponent) ~= "table" then - table.insert( - errors, - "Component '" .. componentName .. "' state '" .. stateName .. "' must be a table" - ) - end - end - end - end - - -- Validate scaleCorners if present - if component.scaleCorners ~= nil then - if type(component.scaleCorners) ~= "number" then - table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be a number") - elseif component.scaleCorners <= 0 then - table.insert(errors, "Component '" .. componentName .. "' scaleCorners must be positive") - end - end - - -- Validate scalingAlgorithm if present - if component.scalingAlgorithm ~= nil then - if type(component.scalingAlgorithm) ~= "string" then - table.insert(errors, "Component '" .. componentName .. "' scalingAlgorithm must be a string") - elseif component.scalingAlgorithm ~= "nearest" and component.scalingAlgorithm ~= "bilinear" then - table.insert( - errors, - "Component '" .. componentName .. "' scalingAlgorithm must be 'nearest' or 'bilinear'" - ) - end - end - end - end - end - end - - -- Scrollbars validation (optional) - if theme.scrollbars ~= nil then - if type(theme.scrollbars) ~= "table" then - table.insert(errors, "Theme 'scrollbars' must be a table") - else - for scrollbarName, scrollbarDef in pairs(theme.scrollbars) do - if type(scrollbarDef) == "table" then - -- Check if it has bar/frame subcomponents - if scrollbarDef.bar or scrollbarDef.frame then - -- Validate bar subcomponent - if scrollbarDef.bar ~= nil then - if type(scrollbarDef.bar) ~= "string" and type(scrollbarDef.bar) ~= "table" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' bar must be a string or table") - end - end - -- Validate frame subcomponent - if scrollbarDef.frame ~= nil then - if type(scrollbarDef.frame) ~= "string" and type(scrollbarDef.frame) ~= "table" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' frame must be a string or table") - end - end - else - -- Validate as a single ThemeComponent - -- Validate atlas if present - if scrollbarDef.atlas ~= nil and type(scrollbarDef.atlas) ~= "string" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' atlas must be a string") - end - - -- Validate insets if present - if scrollbarDef.insets ~= nil then - if type(scrollbarDef.insets) ~= "table" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must be a table") - else - for _, side in ipairs({ "left", "top", "right", "bottom" }) do - if scrollbarDef.insets[side] == nil then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets must have '" .. side .. "' field") - elseif type(scrollbarDef.insets[side]) ~= "number" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be a number") - elseif scrollbarDef.insets[side] < 0 then - table.insert( - errors, - "Scrollbar '" .. scrollbarName .. "' insets." .. side .. " must be non-negative" - ) - end - end - end - end - - -- Validate states if present - if scrollbarDef.states ~= nil then - if type(scrollbarDef.states) ~= "table" then - table.insert(errors, "Scrollbar '" .. scrollbarName .. "' states must be a table") - else - for stateName, stateComponent in pairs(scrollbarDef.states) do - if type(stateComponent) ~= "table" then - table.insert( - errors, - "Scrollbar '" .. scrollbarName .. "' state '" .. stateName .. "' must be a table" - ) - end - end - end - end - end - end - end - end - end - - -- contentAutoSizingMultiplier validation (optional) - if theme.contentAutoSizingMultiplier ~= nil then - if type(theme.contentAutoSizingMultiplier) ~= "table" then - table.insert(errors, "Theme 'contentAutoSizingMultiplier' must be a table") - else - if theme.contentAutoSizingMultiplier.width ~= nil then - if type(theme.contentAutoSizingMultiplier.width) ~= "number" then - table.insert(errors, "contentAutoSizingMultiplier.width must be a number") - elseif theme.contentAutoSizingMultiplier.width <= 0 then - table.insert(errors, "contentAutoSizingMultiplier.width must be positive") - end - end - if theme.contentAutoSizingMultiplier.height ~= nil then - if type(theme.contentAutoSizingMultiplier.height) ~= "number" then - table.insert(errors, "contentAutoSizingMultiplier.height must be a number") - elseif theme.contentAutoSizingMultiplier.height <= 0 then - table.insert(errors, "contentAutoSizingMultiplier.height must be positive") - end - end - end - end - - -- Global atlas validation (optional) - if theme.atlas ~= nil then - if type(theme.atlas) ~= "string" then - table.insert(errors, "Theme 'atlas' must be a string") - end - end - - -- Strict mode: warn about unknown fields - if options.strict then - local knownFields = { - name = true, - atlas = true, - components = true, - scrollbars = true, - colors = true, - fonts = true, - contentAutoSizingMultiplier = true, - } - for field in pairs(theme) do - if not knownFields[field] then - table.insert(errors, "Unknown field '" .. field .. "' in theme") - end - end - end - - return #errors == 0, errors -end - ---- Clean up malformed theme data to make it usable without crashing ---- Use this to robustly handle user-created or external themes ----@param theme table? The theme to sanitize ----@return table sanitized The sanitized theme -function Theme.sanitizeTheme(theme) - local sanitized = {} - - -- Handle nil theme - if theme == nil then - return { name = "Invalid Theme" } - end - - -- Handle non-table theme - if type(theme) ~= "table" then - return { name = "Invalid Theme" } - end - - -- Sanitize name - if type(theme.name) == "string" and theme.name ~= "" then - sanitized.name = theme.name - else - sanitized.name = "Unnamed Theme" - end - - -- Sanitize colors - if type(theme.colors) == "table" then - sanitized.colors = {} - for colorName, colorValue in pairs(theme.colors) do - if type(colorName) == "string" then - local colorType = type(colorValue) - if colorType == "table" and colorValue.r and colorValue.g and colorValue.b then - -- Valid Color object - sanitized.colors[colorName] = colorValue - elseif colorType == "string" then - -- Try to validate color string - local isValid = Theme._Color.validateColor(colorValue) - if isValid then - sanitized.colors[colorName] = colorValue - else - -- Provide fallback color - sanitized.colors[colorName] = Theme._Color.new(0, 0, 0, 1) - end - end - end - end - end - - -- Sanitize fonts - if type(theme.fonts) == "table" then - sanitized.fonts = {} - for fontName, fontPath in pairs(theme.fonts) do - if type(fontName) == "string" and type(fontPath) == "string" then - sanitized.fonts[fontName] = fontPath - end - end - end - - -- Sanitize components (preserve as-is, they're complex) - if type(theme.components) == "table" then - sanitized.components = theme.components - end - - -- Sanitize scrollbars (preserve as-is, they're complex like components) - if type(theme.scrollbars) == "table" then - sanitized.scrollbars = theme.scrollbars - end - - -- Sanitize contentAutoSizingMultiplier - if type(theme.contentAutoSizingMultiplier) == "table" then - sanitized.contentAutoSizingMultiplier = {} - if type(theme.contentAutoSizingMultiplier.width) == "number" and theme.contentAutoSizingMultiplier.width > 0 then - sanitized.contentAutoSizingMultiplier.width = theme.contentAutoSizingMultiplier.width - end - if type(theme.contentAutoSizingMultiplier.height) == "number" and theme.contentAutoSizingMultiplier.height > 0 then - sanitized.contentAutoSizingMultiplier.height = theme.contentAutoSizingMultiplier.height - end - end - - -- Sanitize atlas - if type(theme.atlas) == "string" then - sanitized.atlas = theme.atlas - end - - return sanitized -end - -return Theme diff --git a/libs/flexlove/modules/UTF8.lua b/libs/flexlove/modules/UTF8.lua deleted file mode 100644 index ba2adc95..00000000 --- a/libs/flexlove/modules/UTF8.lua +++ /dev/null @@ -1,44 +0,0 @@ ----@class UTF8 ----Compatibility layer for UTF-8 support across Lua versions ----Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks - -local UTF8 = {} - --- Try to load UTF-8 library in order of preference: --- 1. Built-in utf8 (Lua 5.3+, LÖVE2D) --- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2) --- 3. Error if neither available -local function loadUTF8() - -- Try built-in utf8 first (Lua 5.3+ and LÖVE2D) - if utf8 and type(utf8) == "table" and utf8.len then - return utf8 - end - - -- Try lua-utf8 from LuaRocks - local ok, luautf8 = pcall(require, "lua-utf8") - if ok then - return luautf8 - end - - -- Try standard utf8 module name as fallback - ok, luautf8 = pcall(require, "utf8") - if ok then - return luautf8 - end - - -- No UTF-8 library available - error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8") -end - --- Load the UTF-8 implementation -local utf8lib = loadUTF8() - --- Export all utf8 functions -UTF8.char = utf8lib.char -UTF8.charpattern = utf8lib.charpattern -UTF8.codes = utf8lib.codes -UTF8.codepoint = utf8lib.codepoint -UTF8.len = utf8lib.len -UTF8.offset = utf8lib.offset - -return UTF8 diff --git a/libs/flexlove/modules/Units.lua b/libs/flexlove/modules/Units.lua deleted file mode 100644 index aa25707f..00000000 --- a/libs/flexlove/modules/Units.lua +++ /dev/null @@ -1,335 +0,0 @@ ---- Utility module for parsing and resolving CSS-like units (px, %, vw, vh) ---- Provides unit parsing, validation, and conversion to pixel values ----@class Units ----@field _Context table? Context module dependency ----@field _ErrorHandler table? ErrorHandler module dependency ----@field _Calc table? Calc module dependency -local Units = {} - ---- Initialize Units module with dependencies ----@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? } -function Units.init(deps) - Units._Context = deps.Context - Units._ErrorHandler = deps.ErrorHandler - Units._Calc = deps.Calc -end - ---- Parse a unit value into numeric value and unit type ---- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions ----@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object) ----@return number|table numericValue The numeric portion of the value or calc object ----@return string unitType The unit type ("px", "%", "vw", "vh", "calc") -function Units.parse(value) - -- Check if value is a calc expression - if Units._Calc and Units._Calc.isCalc(value) then - return value, "calc" - end - - if type(value) == "number" then - return value, "px" - end - - if type(value) ~= "string" and type(value) ~= "table" then - Units._ErrorHandler:warn("Units", "VAL_001", { - property = "unit value", - expected = "string, number, or calc object", - got = type(value), - }) - return 0, "px" - end - - -- Check for unit-only input (e.g., "px", "%", "vw" without a number) - local validUnits = { px = true, ["%"] = true, vw = true, vh = true } - if validUnits[value] then - Units._ErrorHandler:warn("Units", "VAL_005", { - input = value, - expected = "number + unit (e.g., '50" .. value .. "')", - }) - return 0, "px" - end - - -- Check for invalid format (space between number and unit) - if value:match("%d%s+%a") then - Units._ErrorHandler:warn("Units", "VAL_005", { - input = value, - issue = "contains space between number and unit", - }) - return 0, "px" - end - - -- Match number followed by optional unit - local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$") - if not numStr then - Units._ErrorHandler:warn("Units", "VAL_005", { - input = value, - }) - return 0, "px" - end - - local num = tonumber(numStr) - if not num then - Units._ErrorHandler:warn("Units", "VAL_005", { - input = value, - issue = "numeric value cannot be parsed", - }) - return 0, "px" - end - - -- Default to pixels if no unit specified - if unit == "" then - unit = "px" - end - - -- validUnits is already defined at the top of the function - if not validUnits[unit] then - Units._ErrorHandler:warn("Units", "VAL_005", { - input = value, - unit = unit, - validUnits = "px, %, vw, vh", - }) - return num, "px" - end - - return num, unit -end - ---- Convert relative units to absolute pixel values ---- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions ----@param value number|table Numeric value to convert or calc object ----@param unit string Unit type ("px", "%", "vw", "vh", "calc") ----@param viewportWidth number Current viewport width in pixels ----@param viewportHeight number Current viewport height in pixels ----@param parentSize number? Required for percentage units (parent dimension in pixels) ----@return number resolvedValue Resolved pixel value -function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) - if unit == "calc" then - -- Resolve calc expression - if Units._Calc then - return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize) - else - Units._ErrorHandler:warn("Units", "VAL_006", { - unit = "calc", - issue = "Calc module not available", - }) - return 0 - end - elseif unit == "px" then - return value - elseif unit == "%" then - if not parentSize then - Units._ErrorHandler:warn("Units", "LAY_003", { - unit = "%", - issue = "parent dimension not available", - }) - return 0 - end - return (value / 100) * parentSize - elseif unit == "vw" then - return (value / 100) * viewportWidth - elseif unit == "vh" then - return (value / 100) * viewportHeight - else - Units._ErrorHandler:warn("Units", "VAL_005", { - unit = unit, - validUnits = "px, %, vw, vh, calc", - }) - return 0 - end -end - ---- Get current viewport dimensions ---- Uses cached viewport during resize operations, otherwise queries LÖVE graphics ----@return number width Viewport width in pixels ----@return number height Viewport height in pixels -function Units.getViewport() - -- Return cached viewport if available (only during resize operations) - if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then - return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height - end - - if love.graphics and love.graphics.getDimensions then - return love.graphics.getDimensions() - else - local w, h = love.window.getMode() - return w, h - end -end - ---- Apply base scale factor to a value based on axis ---- Used for responsive scaling of UI elements ----@param value number The value to scale ----@param axis "x"|"y" The axis to scale on ----@param scaleFactors {x:number, y:number} Scale factors for each axis ----@return number scaledValue The scaled value -function Units.applyBaseScale(value, axis, scaleFactors) - if axis == "x" then - return value * scaleFactors.x - else - return value * scaleFactors.y - end -end - ---- Resolve spacing properties (margin, padding) to pixel values ---- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal) ----@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal ----@param parentWidth number Parent element width in pixels ----@param parentHeight number Parent element height in pixels ----@return table resolvedSpacing Table with top, right, bottom, left in pixels -function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) - if not spacingProps then - return { top = 0, right = 0, bottom = 0, left = 0 } - end - - local viewportWidth, viewportHeight = Units.getViewport() - local result = {} - - local vertical = spacingProps.vertical - local horizontal = spacingProps.horizontal - - if vertical then - if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then - local value, unit = Units.parse(vertical) - vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) - end - end - - if horizontal then - if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then - local value, unit = Units.parse(horizontal) - horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) - end - end - - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - local value = spacingProps[side] - if value then - if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then - local numValue, unit = Units.parse(value) - local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth - result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize) - else - result[side] = value - end - else - if side == "top" or side == "bottom" then - result[side] = vertical or 0 - else - result[side] = horizontal or 0 - end - end - end - - return result -end - ---- Validate a unit string format ---- Checks if the string can be successfully parsed as a valid unit or calc expression ----@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object ----@return boolean isValid True if the unit string is valid, false otherwise -function Units.isValid(unitStr) - -- Check if it's a calc expression - if Units._Calc and Units._Calc.isCalc(unitStr) then - return true - end - - if type(unitStr) ~= "string" then - return false - end - - -- Check for invalid format (space between number and unit) - if unitStr:match("%d%s+%a") then - return false - end - - -- Match number followed by optional unit - local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$") - if not numStr then - return false - end - - -- Check if numeric part is valid - local num = tonumber(numStr) - if not num then - return false - end - - -- Default to pixels if no unit specified - if unit == "" then - unit = "px" - end - - -- Check if unit is valid - local validUnits = { px = true, ["%"] = true, vw = true, vh = true } - return validUnits[unit] == true -end - ---- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis ---- Supports: number, "auto", "none", "grow shrink basis" ----@param flexValue number|string The flex shorthand value ----@return number flexGrow ----@return number flexShrink ----@return string|number flexBasis -function Units.parseFlexShorthand(flexValue) - -- Single number: flex-grow - if type(flexValue) == "number" then - return flexValue, 1, 0 - end - - -- String values - if type(flexValue) == "string" then - -- "auto" = 1 1 auto - if flexValue == "auto" then - return 1, 1, "auto" - end - - -- "none" = 0 0 auto - if flexValue == "none" then - return 0, 0, "auto" - end - - -- Parse "grow shrink basis" format - local parts = {} - for part in flexValue:gmatch("%S+") do - table.insert(parts, part) - end - - local grow = 0 - local shrink = 1 - local basis = "auto" - - if #parts == 1 then - -- Single value: could be grow (number) or basis (with unit) - local num = tonumber(parts[1]) - if num then - grow = num - basis = 0 - else - basis = parts[1] - end - elseif #parts == 2 then - -- Two values: grow shrink (both numbers) or grow basis - local num1 = tonumber(parts[1]) - local num2 = tonumber(parts[2]) - if num1 and num2 then - grow = num1 - shrink = num2 - basis = 0 - elseif num1 then - grow = num1 - basis = parts[2] - end - elseif #parts >= 3 then - -- Three values: grow shrink basis - grow = tonumber(parts[1]) or 0 - shrink = tonumber(parts[2]) or 1 - basis = parts[3] - end - - return grow, shrink, basis - end - - -- Default fallback - return 0, 1, "auto" -end - -return Units diff --git a/libs/flexlove/modules/ZIndex.lua b/libs/flexlove/modules/ZIndex.lua deleted file mode 100644 index e10bdf56..00000000 --- a/libs/flexlove/modules/ZIndex.lua +++ /dev/null @@ -1,35 +0,0 @@ ----@class ZIndex -local ZIndex = {} - --- The effective z-index formula used for sorting is: --- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ --- where rootZ is the z-index of the top-level ancestor, depth is the --- nesting level, and ownZ is the element's own z property. --- --- Constraints enforced by these weights: --- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits) --- DEPTH_WEIGHT has enough room for depths well beyond any practical tree --- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision ---- ----@type integer -ZIndex.MIN_Z = -999 ----@type integer -ZIndex.MAX_Z = 999 ----@type integer -ZIndex.ROOT_WEIGHT = 10000000000 ----@type integer -ZIndex.DEPTH_WEIGHT = 1000 - ---- Clamp a z-index value to the valid range ----@param value number ----@return integer -function ZIndex.clamp(value) - if value < ZIndex.MIN_Z then - return ZIndex.MIN_Z - elseif value > ZIndex.MAX_Z then - return ZIndex.MAX_Z - end - return value -end - -return ZIndex diff --git a/libs/flexlove/modules/behaviors/Animated.lua b/libs/flexlove/modules/behaviors/Animated.lua deleted file mode 100644 index 16592025..00000000 --- a/libs/flexlove/modules/behaviors/Animated.lua +++ /dev/null @@ -1,245 +0,0 @@ --- modules/behaviors/Animated.lua --- --- Concrete behavior: animation update, interpolation application, chaining --- resolution, and transition wiring. --- --- Task 06 of the behavior-mode-unification refactor. Moves the entire --- animation-update block out of Element:update (lines ~2761-2800) into --- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule` --- init-time wiring into `Animated.onAttach(element)`. --- --- This behavior is UNIQUE among the behavior set because it can attach --- AFTER element creation. Animation is opt-in: a plain Element created without --- `transitions` and without an `animation` field never attaches Animated. --- The moment something creates an animation on the element — either directly --- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a --- transition firing in `setProperty` — `Animated.ensureAttached(element)` --- attaches this behavior on demand so subsequent `Element:update` frames --- dispatch to `Animated.onUpdate`. --- --- Attachment rule (shouldAttach): true when `props.transitions` is set OR an --- `element.animation` already exists at runtime. The runtime arm covers the --- late-attach case (animateTo / fadeIn / direct animation assignment). --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (`element.animation`). --- * The behavior instance itself is stateless and shared across elements. --- * Element-class-level dependencies (Element._Animation, Element._Color, --- Element._Transform) are resolved from the owning element's metatable, --- exactly like Clickable does — keeping the behavior stateless without --- expanding the 6-hook signature. --- --- saveState/restoreState are no-ops: animations are ephemeral (an in-flight --- animation is not part of immediate-mode persisted state — the next frame --- re-evaluates transitions / re-applies animations fresh). Persisted scalar --- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block, --- not via the animation. - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance. --- Element instances are created via `setmetatable({}, Element)` in _construct, --- so their metatable IS the Element class — giving us Element._Animation, --- Element._Color, Element._Transform, etc. without threading deps through the --- behavior hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ---------------------------------------------------------------------------- --- ensureAnimationModuleWiring — set Element._Animation._ColorModule / --- _TransformModule. Idempotent; called from both onAttach and onUpdate so it --- works even when an animation was assigned by a caller that bypassed --- onAttach (direct `element.animation = Animation.new(...)`). --- ---------------------------------------------------------------------------- - -local function ensureAnimationModuleWiring(element) - local Element = ElementClass(element) - local Animation = Element._Animation - if not Animation then - return - end - -- Ensure animation has Color module reference for color interpolation - if not Animation._ColorModule and Element._Color then - Animation._ColorModule = Element._Color - end - -- Ensure animation has Transform module reference for transform interpolation - if not Animation._TransformModule and Element._Transform then - Animation._TransformModule = Element._Transform - end -end - --- ---------------------------------------------------------------------------- --- shouldAttach (class-level predicate, no element required) --- ---------------------------------------------------------------------------- - --- True when the element declares transitions up front OR already has an --- animation attached. The `animation` arm is consulted by ensureAttached at --- runtime (after creation); the `transitions` arm lets Animated auto-attach --- during Element.new for elements that pre-declare transitions. -local function shouldAttach(props) - if not props then - return false - end - if props.transitions ~= nil then - return true - end - -- Late-attach case: an animation was assigned after creation. When ensure - -- Attached passes the element instance as `props`, this arm catches it. - if type(props) == "table" and props.animation ~= nil then - return true - end - return false -end - --- ---------------------------------------------------------------------------- --- ensureAttached — dynamic late-attach entry point --- ---------------------------------------------------------------------------- - --- Idempotently attach the Animated behavior to an element that just gained an --- animation (via animateTo / fadeIn / direct assignment / a firing transition --- in setProperty). Called from Element.setProperty when a transition fires and --- from the transition helper methods on Element. Safe to call when already --- attached (no-op / returns false). --- --- `animatedBehavior` is the shared behavior instance resolved lazily by --- Element (see Element._resolveAnimatedBehavior). The behavior is looked up --- from the registry once and cached on the class. --- --- Returns true if the behavior was attached this call, false otherwise. -local function ensureAttached(element, animatedBehavior) - if not element or not animatedBehavior then - return false - end - -- Already attached? Avoid duplicate entries within one element lifetime - -- (a behavior may legitimately be re-added across immediate-mode frames - -- since Element is recreated each frame, but within one lifetime at most - -- once). - local behaviors = element.behaviors - if behaviors then - for i = 1, #behaviors do - if behaviors[i] == animatedBehavior then - return false - end - end - end - table.insert(element.behaviors, animatedBehavior) - animatedBehavior.onAttach(element) - return true -end - --- ---------------------------------------------------------------------------- --- onAttach — initialize Animation module references (formerly the --- Element._Animation._ColorModule / _TransformModule wiring in Element:update --- lines ~2772-2778). --- ---------------------------------------------------------------------------- - -local function onAttach(element) - ensureAnimationModuleWiring(element) -end - --- ---------------------------------------------------------------------------- --- onUpdate — the animation update + interpolation + chain-resolution block --- (formerly Element:update lines ~2761-2800). --- ---------------------------------------------------------------------------- - -local function onUpdate(element, dt) - local animation = element.animation - if not animation then - return - end - - -- (Re)ensure module wiring is present in case the Animation instance was - -- created by a caller that bypassed onAttach (e.g. direct - -- `element.animation = Animation.new(...)`). Cheap idempotent writes. - ensureAnimationModuleWiring(element) - - local finished = animation:update(dt, element) - if finished then - -- Animation:update() already called onComplete callback. - -- Check for chained animation. - if animation._next then - element.animation = animation._next - elseif animation._nextFactory and type(animation._nextFactory) == "function" then - local success, nextAnim = pcall(animation._nextFactory, element) - if success and nextAnim then - element.animation = nextAnim - else - element.animation = nil - end - else - element.animation = nil - end - else - -- Apply animation interpolation during update. - animation:applyInterpolation(element) - end -end - --- ---------------------------------------------------------------------------- --- saveState / restoreState — no-ops (animations are ephemeral). --- ---------------------------------------------------------------------------- - --- Animations are not persisted across immediate-mode frames — they are --- re-derived each frame from transitions / direct calls. The element's scalar --- props (opacity, x, ...) are persisted by Element.saveState's _props block, --- so a completed animation's final visual state still survives recreation. --- While an animation is mid-flight in immediate mode, the element is recreated --- and the animation is NOT carried over (intentional — animating in immediate --- mode requires setting up the animation each frame). -local function saveState() - return nil -end - -local function restoreState() - return nil -end - --- ---------------------------------------------------------------------------- --- Build the (stateless, shared) behavior instance. --- ---------------------------------------------------------------------------- - --- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no --- behavior-local state and animations have no draw pass). Animation state lives --- on the element (`element.animation`); nothing to tear down on detach. --- --- We build the immutable behavior via Behavior.new (for validation + freeze + --- isBehavior parity with Clickable), then expose the late-attach helper on a --- thin module table since the frozen instance cannot accept new keys. The --- module table passes the behavior to the registry while making --- `Animated.ensureAttached` callable from Element.setProperty / the transition --- helpers — exactly as the task spec requires. -local behavior = Behavior.new({ - onAttach = onAttach, - onUpdate = onUpdate, - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Thin module table: exposes the behavior instance (for the registry) plus the --- late-attach helper (for Element.setProperty). All hooks delegate to the --- frozen behavior instance so dispatch sites get the validated, frozen --- implementation. shouldAttach is also exposed at module level (mirrors --- Clickable.shouldAttach) for tests/callers without an element. -local Animated = { - behavior = behavior, - ensureAttached = ensureAttached, - shouldAttach = shouldAttach, - onAttach = onAttach, - onUpdate = onUpdate, -} - --- Metatable so the module table itself satisfies the duck-typed registry --- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach` --- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the --- frozen behavior instance for every hook. -setmetatable(Animated, { - __index = behavior, - __tostring = function() - return "Animated" - end, -}) - -return Animated diff --git a/libs/flexlove/modules/behaviors/Clickable.lua b/libs/flexlove/modules/behaviors/Clickable.lua deleted file mode 100644 index 811ae11e..00000000 --- a/libs/flexlove/modules/behaviors/Clickable.lua +++ /dev/null @@ -1,344 +0,0 @@ --- modules/behaviors/Clickable.lua --- --- Concrete behavior: mouse/touch event handling, pressed-state tracking, --- hit-testing, and theme-state sync. --- --- This is the largest behavior in the behavior-mode-unification refactor --- (~200 LOC moved out of Element:update / _initSubSystems / saveState). --- Task 02 extracts the entire `if self.onEvent or self.themeComponent or --- self.editable or self._selectState or self.selectOption then ... end` block --- from Element:update (hit-testing, mouse/touch event processing, immediate- --- mode state save, theme-state update) plus EventHandler creation (formerly the --- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a --- render layer in Renderer) plus EventHandler save/restore. --- --- Attachment rule (shouldAttach): the same predicate that previously guarded --- mouse-event processing in Element:update. An element owns the EventHandler / --- gets press feedback exactly when it is interactive: when it declares an --- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a --- Select group (selectParent / selectOption). A plain passive element never --- attaches Clickable and therefore never allocates an EventHandler. --- --- Element retains only the `self._eventHandler` field; Clickable owns it on --- attach. All other Element paths that touched the EventHandler (handleTouchEvent, --- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep --- working unchanged for non-clickable elements. --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.). --- * The behavior instance itself is stateless and shared across elements. --- * Element-class-level dependencies (EventHandler factory, StateManager, --- Context) are resolved from the owning element's metatable (the Element --- class set by Element:_construct). This keeps the behavior stateless while --- avoiding a dependency-injection parameter that would violate the locked --- 6-hook signature `(element, ...)`. - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance. --- Element instances are created via `setmetatable({}, Element)` in _construct, --- so their metatable IS the Element class — giving us Element._EventHandler, --- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc. --- without threading deps through the behavior hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ---------------------------------------------------------------------------- --- shouldAttach (class-level predicate, no element required) --- ---------------------------------------------------------------------------- - --- Mirrors the cases that previously caused Element to allocate + use an --- EventHandler. MUST cover every element that touches the EventHandler at --- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse --- interaction (editable), Select groups (selectParent / selectOption), touch --- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent / --- selectOption are the props that produce _selectState during _initSubSystems; --- checking the props (rather than the runtime _selectState) lets shouldAttach --- run before the Select subsystem is initialized. -local function shouldAttach(props) - props = props or {} - return props.onEvent ~= nil - or props.themeComponent ~= nil - or props.editable == true - or props.onTouchEvent ~= nil - or props.onGesture ~= nil - or props.selectOption ~= nil - or props.selectParent ~= nil -end - --- ---------------------------------------------------------------------------- --- onAttach — create the EventHandler (formerly Element:_initSubSystems --- lines ~640-690) and restore immediate-mode EventHandler state. --- ---------------------------------------------------------------------------- - -local function onAttach(element) - local Element = ElementClass(element) - - local eventHandlerConfig = { - -- element.onEvent is source of truth; not cached on handler - onEventDeferred = element.onEventDeferred, - -- element.onTouchEvent is source of truth; not cached on handler - onTouchEventDeferred = element.onTouchEventDeferred, - -- element.onGesture is source of truth; not cached on handler - onGestureDeferred = element.onGestureDeferred, - touchEnabled = element.touchEnabled, - multiTouchEnabled = element.multiTouchEnabled, - } - - -- In immediate mode, restore EventHandler state from StateManager so pressed - -- / hovered / click-count survive the per-frame element recreation cycle. - -- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11): - -- in retained mode the eventHandler persists, so nothing to restore. - if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then - local state = Element._StateManager.getState(element._stateId) - if state then - -- Restore EventHandler state from StateManager (sparse storage — provide defaults) - eventHandlerConfig._pressed = state._pressed or {} - eventHandlerConfig._lastClickTime = state._lastClickTime - eventHandlerConfig._lastClickButton = state._lastClickButton - eventHandlerConfig._clickCount = state._clickCount or 0 - eventHandlerConfig._dragStartX = state._dragStartX or {} - eventHandlerConfig._dragStartY = state._dragStartY or {} - eventHandlerConfig._lastMouseX = state._lastMouseX or {} - eventHandlerConfig._lastMouseY = state._lastMouseY or {} - eventHandlerConfig._hovered = state._hovered - end - end - - element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps) -end - -local function onDetach(element) - -- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the - -- element's closure references can be collected in immediate mode (formerly - -- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY - -- kept: Element:_cleanup preserves element structure for inspection (the - -- stale-element refs are released when the element is GC'd). onEvent, - -- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler - -- read those directly from the element (not the cache), so clearing them - -- would break retained mode. - element.onFocus = nil - element.onBlur = nil -end - --- ---------------------------------------------------------------------------- --- onUpdate — the mouse hit-testing + event-processing + theme-state + --- immediate-mode save block (formerly Element:update lines ~2813-2960). --- ---------------------------------------------------------------------------- - -local function onUpdate(element, dt) - local Element = ElementClass(element) - local eventHandler = element._eventHandler - if not eventHandler then - return - end - - local mx, my = love.mouse.getPosition() - - -- Clickable area is the border box (x, y already includes padding) - -- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection - 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) - - -- Account for scroll offsets from parent containers - -- Walk up the parent chain and accumulate scroll offsets. This stays in - -- Clickable because it's an interaction concern (hit-testing), not layout. - local scrollOffsetX = 0 - local scrollOffsetY = 0 - local current = element.parent - while current do - local overflowX = current.overflowX or current.overflow - local overflowY = current.overflowY or current.overflow - local hasScrollableOverflow = ( - overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - or overflowX == "hidden" - or overflowY == "hidden" - ) - if hasScrollableOverflow then - scrollOffsetX = scrollOffsetX + (current._scrollX or 0) - scrollOffsetY = scrollOffsetY + (current._scrollY or 0) - end - current = current.parent - end - - -- Adjust mouse position by accumulated scroll offset for hit testing - local adjustedMx = mx + scrollOffsetX - local adjustedMy = my + scrollOffsetY - local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh - - -- Check if this is the topmost interactive element at the mouse position - -- (z-index ordering). This prevents blocked/occluded elements from - -- receiving interactions or visual feedback. A single mode-agnostic lookup - -- via `Context.findInteractiveAtPosition` (unified-event-routing task 05) - -- replaces the previous immediate/retained-mode split that used - -- `getTopElementAt` in immediate mode and `_activeEventElement` in retained - -- mode. `findInteractiveAtPosition` routes every hit test through - -- `pointHitsElement` (the single canonical `display == false` guard) and - -- resolves occlusion by z-index in both modes, so the active element is the - -- same one that would receive a hit under the cursor. - local topElement = Element._Context.findInteractiveAtPosition(mx, my) - local isActiveElement = (topElement == element or topElement == nil) - - -- Reset scrollbar press flag at start of each frame - eventHandler:resetScrollbarPressFlag() - - -- Process mouse events through EventHandler FIRST - -- This ensures pressed states are updated before theme state is calculated - eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement) - - -- In immediate mode, save EventHandler state to StateManager after - -- processing events so it survives the per-frame recreation. - if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then - local eventHandlerState = eventHandler:getState() - Element._StateManager.updateState(element._stateId, { - _pressed = eventHandlerState._pressed, - _lastClickTime = eventHandlerState._lastClickTime, - _lastClickButton = eventHandlerState._lastClickButton, - _clickCount = eventHandlerState._clickCount, - _dragStartX = eventHandlerState._dragStartX, - _dragStartY = eventHandlerState._dragStartY, - _lastMouseX = eventHandlerState._lastMouseX, - _lastMouseY = eventHandlerState._lastMouseY, - _hovered = eventHandlerState._hovered, - }) - end - - -- Update theme state based on interaction. themeComponent state update - -- lives in Clickable because it is driven by hover/press state; the actual - -- theme RENDERING is the Themed behavior (task 07). - if element.themeComponent then - -- Check if any button is pressed via EventHandler - local anyPressed = eventHandler:isAnyButtonPressed() - - -- Update theme state via ThemeManager - local isFocused = Element._Context.getFocused() == element - local newThemeState = - element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled) - - if element._stateId and Element._Context.isImmediateMode() then - local hover = newThemeState == "hover" - local pressed = newThemeState == "pressed" - local focused = isFocused - - Element._StateManager.updateState(element._stateId, { - hover = hover, - pressed = pressed, - focused = focused, - disabled = element.disabled, - active = element.active, - }) - end - - if element._renderer then - element._renderer:setThemeState(newThemeState) - end - end - - -- Process touch events through EventHandler - eventHandler:processTouchEvents(element) -end - --- ---------------------------------------------------------------------------- --- onDraw — pressed-state visual feedback (formerly Renderer Layer 5). --- ---------------------------------------------------------------------------- - --- Draws the grey pressed overlay when any mouse button is currently pressed on --- the element. Delegates the actual pixels to Renderer:drawPressedState (which --- owns the RoundedRect + opacity math) but drives the DECISION + transform --- context here, so the renderer no longer needs the `if element.onEvent ...` --- behavioral branch. Honors disableHighlight (themes handle their own visual --- feedback) exactly as the old render layer did. -local function onDraw(element) - if element.disableHighlight then - return - end - local eventHandler = element._eventHandler - if not eventHandler then - return - end - - local anyPressed = false - local pressedState = eventHandler:getState()._pressed or {} - for _, pressed in pairs(pressedState) do - if pressed then - anyPressed = true - break - end - end - if not anyPressed then - return - end - - local renderer = element._renderer - if not renderer then - return - end - - 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) - - -- Apply the element transform around the overlay, mirroring how the - -- Renderer wrapped its whole command buffer (pressed state was a render - -- layer subject to the same transform). - local Element = ElementClass(element) - local Transform = Element._Transform - local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform) - if hasTransform then - Transform.apply(element.transform, element.x, element.y, element.width, element.height) - end - - renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius) - - if hasTransform then - Transform.unapply() - end -end - --- ---------------------------------------------------------------------------- --- saveState / restoreState — EventHandler state (formerly the eventHandler --- branches of Element:saveState / Element:restoreState). --- ---------------------------------------------------------------------------- - -local function saveState(element) - if element._eventHandler then - return { eventHandler = element._eventHandler:getState() } - end - return nil -end - -local function restoreState(element, state) - if not state then - return nil - end - if element._eventHandler and state.eventHandler then - element._eventHandler:setState(state.eventHandler) - end - return nil -end - --- ---------------------------------------------------------------------------- --- Build the (stateless, shared, immutable) behavior instance. --- ---------------------------------------------------------------------------- - -local Clickable = Behavior.new({ - onAttach = onAttach, - onDetach = onDetach, - onUpdate = onUpdate, - onDraw = onDraw, - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Behavior.shouldAttach). -Clickable.shouldAttach = shouldAttach - -return Clickable diff --git a/libs/flexlove/modules/behaviors/Imageable.lua b/libs/flexlove/modules/behaviors/Imageable.lua deleted file mode 100644 index b448a47b..00000000 --- a/libs/flexlove/modules/behaviors/Imageable.lua +++ /dev/null @@ -1,282 +0,0 @@ --- modules/behaviors/Imageable.lua --- --- Concrete behavior: image loading + image rendering config. --- --- Imageable owns the image side of the Renderer: it runs the deferred image- --- load pipeline (cache check → defer → load → fire onImageLoad/onImageError --- callbacks), populates the resolved `_loadedImage` cache on both the element --- and the shared renderer, and persists that cache across immediate-mode --- recreation. It is the behavior-mode-unification replacement for the image- --- loading half of Element:_initImageAndRenderer and the deferred --- Element:_loadImage method (behavior-mode-unification task 07). --- --- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/ --- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read --- from the element at draw time (Renderer._executeDrawCommand image branch) — --- Imageable does NOT mirror them onto the renderer, so bare writes and --- setProperty(...) are immediately consistent. Only the resolved _loadedImage --- cache (the love.Image produced by the load pipeline) is renderer-mirrored, --- because Renderer:draw reads `self._loadedImage`. --- --- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and --- the bare-write-equivalent setImage* flows route through element._reloadImage --- (installed below) which re-runs the load pipeline. See --- TestRetainedPropertyConsistency (image props) and TestImageableIntegration. --- --- Attachment rule (shouldAttach): an element owns image concern exactly when it --- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded --- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches. --- --- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur --- config; Imageable.onAttach enriches the SAME renderer instance with image --- config + kicks off loading. They share `element._renderer`. In the registry --- Imageable runs after Themed, so the renderer already exists; the create-or- --- reuse guard below covers the defensive case where Imageable attaches first. --- --- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call --- (owned by the Themed behavior) which executes the renderer's `image` draw --- command using the config Imageable.onAttach wired. Imageable.onDraw is --- therefore a no-op for the draw call itself — there is no separate --- `_renderer:_drawImage` entry point; pixel emission lives in the integrated --- Renderer:draw command buffer. Splitting it out would require Renderer surgery --- with no behavioral gain (Renderer:draw already conditionally skips the image --- layer when no image is loaded). --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`, --- `element._renderer._loadedImage`). The behavior instance is stateless. --- * saveState/restoreState persist `_loadedImage` across immediate-mode frames --- so the image renders even if the ImageCache is cleared between frames and --- so the renderer's loaded-image cache survives element recreation. - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Lua 5.4 removed the global `unpack`; mirror Element's alias. -local unpack = table.unpack or unpack - --- Resolve the Element class from an element instance (mirrors Clickable/Themed). -local function ElementClass(element) - return getmetatable(element) -end - --- ---------------------------------------------------------------------------- --- shouldAttach (class-level predicate, no element required) --- ---------------------------------------------------------------------------- - -local function shouldAttach(props) - props = props or {} - return props.imagePath ~= nil or props.image ~= nil -end - --- ---------------------------------------------------------------------------- --- Image callback helper (moved from Element._fireImageCallback). --- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall, --- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single --- EVT_002 warn on failure. The direct-`image` sync init path passes --- honorDeferred=false to preserve immediate firing (image is already loaded). --- ---------------------------------------------------------------------------- - -local function fireImageCallback(element, callbackField, honorDeferred, ...) - local cb = element[callbackField] - if type(cb) ~= "function" then - return - end - local Element = ElementClass(element) - local argc = select("#", ...) - local args = { ... } - local function invoke() - local ok, err = pcall(cb, element, unpack(args, 1, argc)) - if not ok then - Element._ErrorHandler:warn("Element", "EVT_002", { - callback = callbackField, - error = tostring(err), - }) - end - end - if honorDeferred and element[callbackField .. "Deferred"] then - Element._Context.deferCallback(invoke) - else - invoke() - end -end - --- ---------------------------------------------------------------------------- --- Deferred image loader (replaces Element:_loadImage). --- --- Invoked by Element's deferred-method dispatcher via the instance closure that --- onAttach installs on `element._loadImage`. Loads the image from cache or disk --- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the --- image draws after an async load, and fires the load/error callback (deferred, --- honoring onImageLoadDeferred / onImageErrorDeferred). --- ---------------------------------------------------------------------------- - -local function loadImage(element) - if not element.imagePath or element.image then - return - end - local Element = ElementClass(element) - local loadedImage, err = Element._ImageCache.load(element.imagePath) - if loadedImage then - element._loadedImage = loadedImage - if element._renderer then - element._renderer._loadedImage = loadedImage - end - fireImageCallback(element, "onImageLoad", true, loadedImage) - else - fireImageCallback(element, "onImageError", true, err or "Unknown error") - end -end - --- ---------------------------------------------------------------------------- --- reloadImage — recompute the loaded-image cache from the current image/imagePath. --- --- This is the single entry point for (re)loading after either initial attach or --- a runtime property change (see Element._specialSetHandlers.imagePath/image, --- which call element:_reloadImage()). Precedence matches onAttach: a direct --- `image` wins over `imagePath`; `nil` for both clears the cache. --- --- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the --- image is already loaded; honorDeferred=false preserves the --- original synchronous init contract). --- * imagePath → cache CHECK only (no I/O) so a cached image can draw this --- frame, then defer the loader (_loadImage) for the actual --- I/O + deferred callbacks. load bails if `image` is later set. --- * neither → clear _loadedImage on both element + renderer. --- --- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition) --- and imagePath/image themselves live on the ELEMENT as source of truth; the --- renderer reads them at draw time, so reloadImage does NOT mirror them onto the --- renderer — only the resolved _loadedImage cache is pushed. --- ---------------------------------------------------------------------------- - -local function reloadImage(element) - local Element = ElementClass(element) - local renderer = element._renderer - if element.image then - element._loadedImage = element.image - if renderer then - renderer._loadedImage = element.image - end - fireImageCallback(element, "onImageLoad", false, element.image) - elseif element.imagePath then - -- Cache check (no I/O). Populate both caches immediately if cached so the - -- image can draw this frame without waiting for the deferred load. - local cached = Element._ImageCache.get(element.imagePath) - element._loadedImage = cached - if renderer then - renderer._loadedImage = cached - end - -- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails - -- if image is set or imagePath is nil by the time it runs). - if element._loadImage then - element:_deferMethod("_loadImage") - end - else - element._loadedImage = nil - if renderer then - renderer._loadedImage = nil - end - end -end - --- ---------------------------------------------------------------------------- --- onAttach — enrich the shared renderer with image config + kick off loading --- (formerly the image block of Element:_initImageAndRenderer). --- ---------------------------------------------------------------------------- - -local function onAttach(element) - local Element = ElementClass(element) - - -- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse - -- guard is defensive for the Imageable-attaches-first ordering). - if not element._renderer then - element._renderer = Element._Renderer.new({ - theme = element.theme, - scaleCorners = element.scaleCorners, - scalingAlgorithm = element.scalingAlgorithm, - contentBlur = element.contentBlur, - backdropBlur = element.backdropBlur, - }, Element._rendererDeps) - end - - -- Install the (re)load hooks as instance methods so Element's - -- deferred-method dispatcher / setProperty special handlers can trigger a - -- reload without Element needing a behavior reference. This keeps Element - -- decoupled from the Imageable behavior (mirrors the stateless-behavior + - -- element-owned-state contract). Image value props and imagePath/image live - -- on the element as source of truth (read at draw time); only the resolved - -- _loadedImage cache is mirrored onto the renderer by reloadImage. - element._loadImage = function(el) - loadImage(el) - end - element._reloadImage = function(el) - reloadImage(el) - end - - -- Initial load: compute _loadedImage + defer the I/O load. - reloadImage(element) -end - --- ---------------------------------------------------------------------------- --- onDraw — no-op (see file header: the image layer is rendered by the integrated --- Renderer:draw call owned by the Themed behavior, using the config wired here). --- ---------------------------------------------------------------------------- - --- ---------------------------------------------------------------------------- --- saveState / restoreState — `_loadedImage` cache (for immediate-mode). --- ---------------------------------------------------------------------------- - -local function saveState(element) - if element._loadedImage ~= nil then - return { _loadedImage = element._loadedImage } - end - return nil -end - -local function restoreState(element, state) - if not state or state._loadedImage == nil then - return nil - end - local loadedImage = state._loadedImage - element._loadedImage = loadedImage - if element._renderer then - element._renderer._loadedImage = loadedImage - end - return nil -end - --- ---------------------------------------------------------------------------- --- onDetach — release image-load callback closures so the element can be GC'd --- cleanly in immediate mode (formerly part of Element:_cleanup). The cached --- `_loadedImage` is reproduced on the next attach via the Imageable saveState --- -> restoreState cycle, so dropping the live references is always safe. --- ---------------------------------------------------------------------------- - -local function onDetach(element) - element.onImageLoad = nil - element.onImageError = nil -end - --- ---------------------------------------------------------------------------- --- Build the (stateless, shared, immutable) behavior instance. --- ---------------------------------------------------------------------------- - -local Imageable = Behavior.new({ - onAttach = onAttach, - onDetach = onDetach, - onUpdate = function() end, - onDraw = function() end, - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Behavior.shouldAttach / --- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior --- instance; it is captured as a module-local upvalue by the onAttach closure that --- installs `element._loadImage`. -Imageable.shouldAttach = shouldAttach - -return Imageable diff --git a/libs/flexlove/modules/behaviors/Persistable.lua b/libs/flexlove/modules/behaviors/Persistable.lua deleted file mode 100644 index 6bcdd0de..00000000 --- a/libs/flexlove/modules/behaviors/Persistable.lua +++ /dev/null @@ -1,132 +0,0 @@ --- modules/behaviors/Persistable.lua --- --- Concrete behavior: generic public-property persistence across the immediate- --- mode recreation cycle (behavior-mode-unification task 12). --- --- Owns the ONE piece of Element save/restore state that is NOT subsystem state: --- the snapshot of an element's own public scalar fields (`text`, `display`, --- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a --- release callback changing `text`, a toggle hiding a panel via `display = --- false`) must survive the per-frame Element recreation that defines immediate --- mode. Persistable captures them in `saveState` and reapplies them in --- `restoreState`, so the caller never branches on mode. --- --- This behavior is the final home for the former `Element:saveState` `_props` --- block and the former `Element:restoreState` `_props` block (~20 LOC moved out --- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState` --- collapse to a pure behavior-dispatch loop and Element owns zero property- --- extraction logic — every persisted slice is owned by exactly one behavior. --- --- Attachment rule (shouldAttach): every element. Persistable attaches --- unconditionally (mirrors the pre-refactor invariant that every element's --- public scalar props were scanned). The actual snapshot is mode-gated inside --- `saveState` (immediate-mode-only, matching the legacy contract); in retained --- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot --- is explicitly passed. --- --- Registry ordering: Persistable is intentionally placed LAST in the behavior --- registry. `restoreState` applies `_props` AFTER every other behavior has --- hydrated its subsystem state, so a persisted public-prop mutation (e.g. --- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state — --- preserving the legacy restore ordering (behaviors first, `_props` tail). --- --- State ownership (per the locked Behavior contract): --- * The persisted props live ON the element (they ARE the element's public --- fields). The behavior instance is stateless + immutable and shared. --- * The snapshot is returned under the `_props` key (prefixed with `_` so --- the public-prop scan itself skips it — avoiding self-recursion). - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance (mirrors Clickable / --- Themed). Element instances are created via `setmetatable({}, Element)`, so --- their metatable IS the Element class — giving access to Element._StateManager --- without threading deps through the hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ============================================================================ --- shouldAttach (class-level predicate, no element required) --- ============================================================================ - --- Every element's public scalar props are persistable, so this behavior --- attaches unconditionally. The mode gate lives inside saveState (it needs the --- runtime mode, which is only available with an element via StateManager). -local function shouldAttach() - return true -end - --- ============================================================================ --- saveState — snapshot public scalar fields (immediate-mode-only). --- ============================================================================ - --- Mirrors the former `Element:saveState` `_props` block exactly: --- * Only string keys NOT prefixed with `_` (so internal fields like --- `_renderer`, `_themeState`, `_initProps` are excluded). --- * Only scalar values (numbers, strings, booleans); tables and functions --- are excluded (children, padding, onEvent, ...). --- Returns `{ _props = {...} }` when there is at least one persistable prop and --- the element is in immediate mode; nil otherwise (retained mode no-op — --- state lives on the element directly there, so nothing to snapshot). -local function saveState(element) - local Element = ElementClass(element) - if not Element._StateManager.isImmediateMode() then - return nil - end - local props = {} - for k, v in pairs(element) do - if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then - props[k] = v - end - end - if next(props) then - return { _props = props } - end - return nil -end - --- ============================================================================ --- restoreState — reapply the persisted public-prop snapshot onto a fresh --- element (mode-agnostic; only fires when a `_props` slice is present). --- ============================================================================ - --- Applies persisted mutations on top of whatever the constructor + other --- behaviors already set, so event-driven changes from the previous frame --- override the declarative props of the recreated element. Runs last in the --- behavior dispatch (Persistable is the registry tail) to preserve the legacy --- restore ordering (subsystem restore first, `_props` override last). -local function restoreState(element, state) - if not state or not state._props then - return - end - for k, v in pairs(state._props) do - element[k] = v - end -end - --- ============================================================================ --- onAttach / onUpdate / onDraw / onDetach — no-ops. --- ============================================================================ - --- Persistable owns no subsystem and allocates no per-element state (the --- "state" it persists IS the element's own fields). The lifecycle is purely --- save/restore. - --- ============================================================================ --- Build the (stateless, shared, immutable) behavior instance. --- ============================================================================ - -local Persistable = Behavior.new({ - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Behavior.shouldAttach / --- Clickable.shouldAttach). -Persistable.shouldAttach = shouldAttach - -return Persistable diff --git a/libs/flexlove/modules/behaviors/Scrollable.lua b/libs/flexlove/modules/behaviors/Scrollable.lua deleted file mode 100644 index 39ad438e..00000000 --- a/libs/flexlove/modules/behaviors/Scrollable.lua +++ /dev/null @@ -1,267 +0,0 @@ --- modules/behaviors/Scrollable.lua --- --- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode --- scrollbar interaction-state restore). --- --- Scrollable owns the per-element ScrollManager instance — the subsystem that --- manages overflow detection, scrollbar geometry, scroll position, and scrollbar --- drag/hover interaction. It is the behavior-mode-unification replacement for --- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new --- (behavior-mode-unification task 03 / landed as part of the task 08 capstone). --- --- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when --- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the --- legacy `if props.overflow or props.overflowX or props.overflowY then` guard --- in `Element:_initScrollManager`. The ScrollManager is created and its --- normalized fields are exposed back onto the element (so the Renderer / --- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth` --- etc.) exactly as the legacy inline phase did. --- --- Why onAttach reads `element._initProps` (not element fields): the scrollbar --- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed / --- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled / --- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius / --- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in --- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven --- `_applyProps` loop — they are consumed only by the ScrollManager constructor. --- The locked behavior hook signature is `(element, ...)` with no props arg, so --- the original construction props are stashed on the element as `_initProps` by --- `Element:_construct` and read back here. (`overflow` / `overflowX` / --- `overflowY` ARE bound onto the element by `_applyProps` so that --- `Element:addChild`'s scroll-container auto-size guard sees them during --- declarative-children processing in `_finalizeConstruction`, which runs BEFORE --- this onAttach; onAttach then overwrites them with the ScrollManager's --- normalized values, matching the legacy field-exposure order.) --- --- onUpdate / onDraw / saveState / restoreState are deferred to the --- behavior-driven update/draw tasks (09 / 12): the ScrollManager update, --- interaction, scrollbar drawing, and state save/restore currently stay inline --- in `Element:update` / `Element:draw` / `Element:saveState` / --- `Element:restoreState` (delegated through the ScrollManager API bound in --- `Element.init`). Those inline call sites are NOT behavioral `if` branches — --- they are unconditional 1-line delegates — so leaving them in Element does not --- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them --- into Scrollable hooks. --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`, --- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...). --- * The behavior instance is stateless + immutable and shared across elements. --- * Element-class-level dependencies (`Element._ScrollManager`, --- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`) --- are resolved from the owning element's metatable (the Element class set by --- `Element:_construct`). - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance. --- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE --- the Element class, so this yields Element._ScrollManager, --- Element._scrollManagerDeps, Element._Context, Element._StateManager without --- threading deps through the hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ---------------------------------------------------------------------------- --- shouldAttach (class-level predicate, no element required) --- ---------------------------------------------------------------------------- - --- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY` --- guard. Uses `~= nil` (rather than truthiness) so that an explicit --- `overflow = false` / `overflow = ""` does not spuriously attach — though in --- practice overflow values are always strings or unset, matching the predicate --- semantics of the other behaviors (Clickable / TextEditable / Selectable). -local function shouldAttach(props) - props = props or {} - return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil -end - --- ---------------------------------------------------------------------------- --- onAttach — create the ScrollManager + expose its fields + restore immediate- --- mode scrollbar interaction state (formerly Element:_initScrollManager). --- ---------------------------------------------------------------------------- - -local function onAttach(element) - local Element = ElementClass(element) - -- Construction props are stashed on the element by _construct (the scrollbar - -- config props are SPECIAL_PROPS and not bound as element fields). - local props = element._initProps or {} - - element._scrollManager = Element._ScrollManager.new({ - overflow = props.overflow, - overflowX = props.overflowX, - overflowY = props.overflowY, - scrollbarWidth = props.scrollbarWidth, - scrollbarColor = props.scrollbarColor, - scrollbarTrackColor = props.scrollbarTrackColor, - scrollbarRadius = props.scrollbarRadius, - scrollbarPadding = props.scrollbarPadding, - scrollSpeed = props.scrollSpeed, - invertScroll = props.invertScroll, - smoothScrollEnabled = props.smoothScrollEnabled, - scrollBarStyle = props.scrollBarStyle, - scrollbarKnobOffset = props.scrollbarKnobOffset, - hideScrollbars = props.hideScrollbars, - scrollbarPlacement = props.scrollbarPlacement, - scrollbarBalance = props.scrollbarBalance, - _scrollX = props._scrollX, - _scrollY = props._scrollY, - }, Element._scrollManagerDeps) - - -- Expose ScrollManager properties for backward compatibility (Renderer access). - local sm = element._scrollManager - element.overflow = sm.overflow - element.overflowX = sm.overflowX - element.overflowY = sm.overflowY - element.scrollbarWidth = sm.scrollbarWidth - element.scrollbarColor = sm.scrollbarColor - element.scrollbarTrackColor = sm.scrollbarTrackColor - element.scrollbarRadius = sm.scrollbarRadius - element.scrollbarPadding = sm.scrollbarPadding - element.scrollSpeed = sm.scrollSpeed - element.invertScroll = sm.invertScroll - element.scrollBarStyle = sm.scrollBarStyle - element.scrollbarKnobOffset = sm.scrollbarKnobOffset - element.hideScrollbars = sm.hideScrollbars - element.scrollbarPlacement = sm.scrollbarPlacement - element.scrollbarBalance = sm.scrollbarBalance - - -- Initialize state properties (will be synced from ScrollManager). - element._overflowX = false - element._overflowY = false - element._contentWidth = 0 - element._contentHeight = 0 - element._scrollX = 0 - element._scrollY = 0 - element._maxScrollX = 0 - element._maxScrollY = 0 - element._scrollbarHoveredVertical = false - element._scrollbarHoveredHorizontal = false - element._scrollbarDragging = false - element._hoveredScrollbar = nil - element._scrollbarDragOffset = 0 - - -- Restore scrollbar state from StateManager in immediate mode (must happen - -- before layout). Mirrors the legacy _initScrollManager restore block. - -- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11). - if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then - local state = Element._StateManager.getState(element._stateId) - if state and state.scrollManager then - element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false - element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false - element._scrollbarDragging = state.scrollManager._scrollbarDragging or false - element._hoveredScrollbar = state.scrollManager._hoveredScrollbar - element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0 - - -- Apply to ScrollManager immediately. - sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical - sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal - sm._scrollbarDragging = element._scrollbarDragging - sm._hoveredScrollbar = element._hoveredScrollbar - sm._scrollbarDragOffset = element._scrollbarDragOffset - - -- Restore drag start positions for relative movement tracking. - sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0 - sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0 - sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0 - sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0 - end - end -end - --- -------------------------------------------------------------------------- --- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction --- (formerly the inline ScrollManager blocks in Element:update). --- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag --- is set before Clickable's EventHandler processes mouse events. --- -------------------------------------------------------------------------- - -local function onUpdate(element, dt) - local Element = ElementClass(element) - local sm = element._scrollManager - if not sm then - return - end - -- Restore scrollbar interaction state from StateManager in immediate mode - -- (no-op outside immediate mode / when no state is stored). - Element._ScrollManager.restoreImmediateState(element) - - -- Smooth-scroll / momentum interpolation. - sm:update(dt) - element:_syncScrollManagerState() - - -- Scrollbar hover / drag / press interaction. Captures the mouse here so the - -- interaction state is consistent across the rest of the frame's behaviors. - local mx, my = love.mouse.getPosition() - Element._ScrollManager.updateInteraction(element, mx, my) -end - --- -------------------------------------------------------------------------- --- onDraw — scrollbar rendering (post-children overlay). Marked --- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so --- scrollbars paint on top of clipped child content and without parent clipping. --- -------------------------------------------------------------------------- - -local function onDraw(element, _ctx) - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then - return - end - local scrollbarDims = element:_calculateScrollbarDimensions() - if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then - return - end - -- Lift the parent scissor while drawing scrollbars so they render fully - -- visible, then RESTORE it: clearing it outright let every later sibling - -- draw unclipped (scrolled page content over the pinned header). - local sx, sy, sw, sh = love.graphics.getScissor() - love.graphics.setScissor() - element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims) - if sx then love.graphics.setScissor(sx, sy, sw, sh) end -end - --- -------------------------------------------------------------------------- --- saveState / restoreState — ScrollManager state snapshot for immediate-mode --- recreation (formerly the inline blocks in Element:saveState/ --- Element:restoreState). Returns a table merged under the `scrollManager` key --- by Element:saveState's behavior loop, mirroring the legacy contract. --- -------------------------------------------------------------------------- - -local function saveState(element) - local sm = element._scrollManager - if not sm then - return nil - end - return { scrollManager = sm:getState() } -end - -local function restoreState(element, state) - if not state then - return - end - local sm = element._scrollManager - local smState = state.scrollManager - if sm and smState then - sm:setState(smState) - end -end - -local Scrollable = Behavior.new({ - onAttach = onAttach, - onDetach = function() end, - onUpdate = onUpdate, - onDraw = onDraw, - saveState = saveState, - restoreState = restoreState, - drawLayer = "overlay", -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Clickable.shouldAttach / --- Selectable.shouldAttach). -Scrollable.shouldAttach = shouldAttach - -return Scrollable diff --git a/libs/flexlove/modules/behaviors/Selectable.lua b/libs/flexlove/modules/behaviors/Selectable.lua deleted file mode 100644 index e63bb4c6..00000000 --- a/libs/flexlove/modules/behaviors/Selectable.lua +++ /dev/null @@ -1,206 +0,0 @@ --- modules/behaviors/Selectable.lua --- --- Concrete behavior: Select state-machine lifecycle for dropdown-style --- select groups. Owns the per-element Select subsystem initialization, the --- managed-frame layout sync each frame, and select save/restore across the --- immediate-mode recreation cycle. --- --- This behavior consolidates the legacy `if self._selectState` / `if --- self.selectOption` branches that previously lived inside Element.lua: --- --- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 — --- `Select.initSelectParent` / `Select.initSelectOption`). --- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700- --- 1702 — `Select.adoptSelectFrame`). --- * Per-frame frame-state sync (formerly Element:update line ~2747 — --- `Select.ensureFrameState`). --- * Save/restore of select open/value/label (formerly the `select` branch of --- Element:saveState / Element:restoreState). --- --- Element retains `self._selectState` and `self.selectOption` for backward- --- compat field access; runtime state lives ON THE ELEMENT. The behavior itself --- is stateless + immutable (a single shared instance attaches to every --- selectable element). --- --- The 20 Element select-API delegate methods (openSelect, closeSelect, --- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as --- 1-line forwarders into the Select module — the behavior owns the --- *lifecycle* (attach / update / save / restore / detach), not the API --- surface (per task 05 spec notes). --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (self._selectState, --- self.selectOption, self._selectParentElement, ...). --- * The behavior instance is stateless + immutable and shared across elements. --- * Element-class-level dependencies are resolved via `getmetatable(element)` --- (which IS the Element class set by Element._construct), so the hook --- signature stays exactly `(element, ...)` with no DI parameters. - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance. --- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE --- the Element class, so this yields Element._Select, Element._Context, --- Element._StateManager, etc. without threading deps through the hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ============================================================================ --- shouldAttach (class-level predicate, no element required) --- ============================================================================ - --- Mirrors the cases that previously caused Element to initialize a Select --- subsystem. An element owns select state exactly when it declares a --- `selectParent` config (the dropdown trigger) or a `selectOption` config (an --- option inside a dropdown). Checking the props (rather than the runtime --- `_selectState`) lets shouldAttach run before onAttach initializes the --- subsystem, matching the auto-attach contract established by Clickable / --- TextEditable. -local function shouldAttach(props) - props = props or {} - return type(props.selectParent) == "table" or type(props.selectOption) == "table" -end - --- ============================================================================ --- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems --- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning --- lines ~1700-1702). --- ============================================================================ - -local function onAttach(element) - local Element = ElementClass(element) - - -- Initialize the appropriate select role. Mirrors the legacy _initSubSystems - -- block exactly: selectParent → initSelectParent (sets _selectState + - -- immediate-mode restore from StateManager); selectOption → initSelectOption - -- (sets the option value/label/disabled). - if type(element.selectParent) == "table" then - Element._Select.initSelectParent(element, element.selectParent) - end - - if type(element.selectOption) == "table" then - Element._Select.initSelectOption(element, element.selectOption) - end - - -- Adopt the managed dropdown frame. This was formerly the tail of - -- _initPositioning (after the select parent's own addChild). It creates the - -- select anchor, reparents the frame under it, and syncs visibility. Moving - -- it here is safe because onAttach runs after _initPositioning: the parent's - -- own positioning is finalized, so the anchor's geometry can be computed. - if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then - Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame) - end - - -- Backfill option registration for children added BEFORE this behavior - -- attached. The auto-attach pass runs at the very end of Element.new - -- (after _finalizeConstruction, which processes declarative `children`). - -- Declarative select-option children are addChild'd to this element during - -- _finalizeConstruction — at that point _selectState did not yet exist (this - -- onAttach had not run), so their registerWithSelectParent call walked the - -- parent chain, found no _selectState, and returned early. Re-scan now that - -- _selectState is initialized so these options are registered + reparented - -- into the managed frame exactly like runtime-added options. - -- (registerWithSelectParent is idempotent — it skips options already - -- registered — so this is a no-op for children added after _selectState was - -- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })` - -- pattern.) - if element._selectState then - for _, child in ipairs(element.children) do - if child.selectOption then - Element._Select.registerWithSelectParent(child) - Element._Select.attachOptionToManagedFrame(child) - end - end - end -end - -local function onDetach(element) - -- Clear select-managed fields so the element can be GC'd cleanly in immediate - -- mode (formerly part of Element:_cleanup). This mirrors the select-clearing - -- block that lived in Element:_cleanup; Element:destroy separately routes - -- through Select.cleanupDestroy for full teardown (idempotent with this). - if element.selectParent then - element.selectParent.onChange = nil - end - element._selectState = nil - element._managedSelectOwner = nil - element._managedSelectFrame = nil - element._managedSelectAnchor = nil - element._managedSelectBaseOpacity = nil - element._managedSelectBaseVisibility = nil - element._managedSelectBaseDisabled = nil -end - --- ============================================================================ --- onUpdate — per-frame managed-frame layout sync (formerly Element:update --- line ~2747 — `Select.ensureFrameState`). --- ============================================================================ - -local function onUpdate(element, dt) - local Element = ElementClass(element) - Element._Select.ensureFrameState(element) -end - --- ============================================================================ --- onDraw — no-op. --- ============================================================================ - --- Select rendering is driven by the managed frame / anchor elements themselves --- (visibility synced by Select.syncManagedFrameVisibility), not by the select --- parent's draw path. The parent's own pixels are the theme/renderer's job. -local function onDraw() end - --- ============================================================================ --- saveState / restoreState — select open/value/label (formerly the `select` --- branch of Element:saveState / Element:restoreState). --- ============================================================================ - --- Returns a snapshot under the `select` key to match the legacy immediate-mode --- restoreState contract (Element:restoreState looked up state.select). The --- behavior-dispatch loop merges behavior snapshots into the top-level state --- table, so returning { select = ... } slots in identically to the old inline --- `state.select = selectState` assignment. -local function saveState(element) - local Element = ElementClass(element) - local selectState = Element._Select.saveState(element) - if selectState then - return { select = selectState } - end - return nil -end - --- Consumes the previously-saved snapshot keyed under `select`. The behavior- --- dispatch loop passes the FULL top-level state table; this hook reads only --- its own `state.select` slice, mirroring the legacy `if state.select then` --- guard in Element:restoreState. -local function restoreState(element, state) - if not state then - return - end - local Element = ElementClass(element) - if state.select then - Element._Select.restoreState(element, state.select) - end -end - --- ============================================================================ --- Build the (stateless, shared, immutable) behavior instance. --- ============================================================================ - -local Selectable = Behavior.new({ - onAttach = onAttach, - onDetach = onDetach, - onUpdate = onUpdate, - onDraw = onDraw, - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Behavior.shouldAttach). -Selectable.shouldAttach = shouldAttach - -return Selectable diff --git a/libs/flexlove/modules/behaviors/TextEditable.lua b/libs/flexlove/modules/behaviors/TextEditable.lua deleted file mode 100644 index 7d3ee79b..00000000 --- a/libs/flexlove/modules/behaviors/TextEditable.lua +++ /dev/null @@ -1,576 +0,0 @@ --- modules/behaviors/TextEditable.lua --- --- Concrete behavior: TextEditor subsystem ownership — text editing, cursor --- management, text selection, text-related input handling, and text-editor --- state save/restore. --- --- This behavior consolidates the legacy `if self._textEditor` nil-guard --- patterns that previously lived inside Element.lua: --- --- * TextEditor creation + immediate-mode state restore (formerly --- Element:_initSubSystems lines ~813-830 — the `if self.editable then --- self._textEditor = Element._TextEditor.new {...}` block). --- * Cursor-blink update (formerly Element:update line ~2810 — --- `if self._textEditor then self._textEditor:update(self, dt) end`). --- * The 27 text-editor delegate methods (formerly Element:setText / --- getText / setCursorPosition / setSelection / focus / textinput / --- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line --- nil-guard stub (check `_textEditor`, forward call, end). They are now --- module-level functions on this behavior; Element retains only 1-line --- forwarders that route through `Element._TextEditable.(self, ...)`. --- * Text-editor state save/restore (formerly the textEditor branch of --- Element:saveState / Element:restoreState), including the cursor/selection --- field sync and the text-selection drag-tracking fields --- (`_mouseDownPosition` / `_textDragOccurred`). --- --- Element retains the `self._textEditor` field for backward-compat field --- access (Renderer:drawText reads it directly for cursor/selection rendering); --- runtime state lives ON THE ELEMENT. The behavior itself is stateless + --- immutable + shared across elements. --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`, --- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior --- instance is stateless + immutable and shared across all editable --- elements. --- * Element-class-level dependencies are resolved via `getmetatable(element)` --- (which IS the Element class set by Element._construct), so the hook --- signature stays exactly `(element, ...)` with no DI parameters. --- --- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's --- command buffer (Layer 4 "text"), driven by the Thamed behavior's single --- `Renderer:draw` call. The Renderer's `drawText` already reads --- `element._textEditor` for cursor/selection, so TextEditable OWNS the --- subsystem that drawText consumes, but the draw dispatch stays in the --- renderer to preserve the unified transform/scissor command-buffer ordering --- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a --- different layer). Hoisting drawText into this behavior's onDraw would --- double-render text, since the Renderer command buffer already emits a "text" --- layer for every element. - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance (mirrors Clickable / --- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance --- metatable BE the Element class, so this yields Element._TextEditor, --- Element._textEditorDeps, Element._Context, Element._StateManager, etc. --- without threading deps through the hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ============================================================================ --- shouldAttach (class-level predicate, no element required) --- ============================================================================ - --- Mirrors the spec predicate: attach when the element is text-editable OR --- carries text content. onAttach only ALLOCATES a TextEditor when --- `element.editable` is true (preserving the pre-refactor creation invariant --- "TextEditor created iff editable"), so non-editable text labels attach the --- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are --- nil-guarded no-ops, and the Element forwarders route them through the --- non-editable branch of each delegate function (reads/writes `element.text` --- directly). This keeps shouldAttach faithful to the spec while preserving --- exact pre-refactor allocation behavior. -local function shouldAttach(props) - props = props or {} - return props.editable == true or props.text ~= nil -end - --- ============================================================================ --- onAttach — create the TextEditor (formerly Element:_initSubSystems lines --- ~813-830) and restore immediate-mode TextEditor state. --- ============================================================================ - -local function onAttach(element) - local Element = ElementClass(element) - - -- Only editable elements own a TextEditor. Preserves the exact pre-refactor - -- creation guard (`if self.editable then ... end`) — non-editable text - -- elements attach the behavior (so their forwarders route through a single - -- code path) but allocate no TextEditor. - if not element.editable then - return - end - - -- Config is sourced from element fields (bound by _applyProps / _initVisualState - -- before _attachBehaviors runs at the tail of Element.new) — NOT from raw - -- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are - -- schema-bound element fields by this point, and `element.text` is set by - -- _initVisualState, so no `props` reference is needed here (the hook - -- signature is `(element)`). - element._textEditor = Element._TextEditor.new({ - editable = element.editable, - multiline = element.multiline, - passwordMode = element.passwordMode, - textWrap = element.textWrap, - maxLines = element.maxLines, - maxLength = element.maxLength, - placeholder = element.placeholder, - inputType = element.inputType, - textOverflow = element.textOverflow, - scrollable = element.scrollable, - autoGrow = element.autoGrow, - selectOnFocus = element.selectOnFocus, - cursorColor = element.cursorColor, - selectionColor = element.selectionColor, - cursorBlinkRate = element.cursorBlinkRate, - text = element.text or "", - onFocus = element.onFocus, - onBlur = element.onBlur, - onTextInput = element.onTextInput, - onTextChange = element.onTextChange, - onEnter = element.onEnter, - }, Element._textEditorDeps) - - -- Restore TextEditor state from StateManager in immediate mode. Mirrors the - -- legacy _initSubSystems immediate-mode restore. Safe to run here (after - -- _construct registered the element with StateManager) — the StateManager - -- lookup is sparse and returns nil for a fresh element. Mode-aware via - -- Context.isImmediateMode (behavior-mode-unification task 11). - if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then - local state = Element._StateManager.getState(element._stateId) - if state and state.textEditor then - element._textEditor:setState(state.textEditor, element) - end - end -end - -local function onDetach(element) - -- Clear text-input callback closures read by TextEditor / KeyboardNavigation - -- so the element's closure references can be collected in immediate mode - -- (formerly part of Element:_cleanup). The TextEditor instance itself is - -- INTENTIONALLY kept: Element:_cleanup preserves element structure for - -- inspection (released when the element is GC'd). - element.onTextInput = nil - element.onTextChange = nil - element.onEnter = nil -end - --- ============================================================================ --- onUpdate — cursor-blink animation (formerly Element:update line ~2810). --- ============================================================================ - --- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on --- `element._textEditor` because non-editable text elements attach this --- behavior (per shouldAttach) but own no TextEditor. Element:update contains --- zero text-editor references — the dispatch loop calls this hook. -local function onUpdate(element, dt) - local textEditor = element._textEditor - if textEditor then - textEditor:update(element, dt) - end -end - --- ============================================================================ --- onDraw — no-op (see file header: text rendering stays in the Renderer --- command buffer driven by the Thamed behavior's Renderer:draw call). --- ============================================================================ - -local function onDraw() end - --- ============================================================================ --- saveState / restoreState — TextEditor state + text-selection drag --- tracking (formerly the textEditor branch of Element:saveState / --- Element:restoreState, including the _mouseDownPosition / _textDragOccurred --- fields). --- ============================================================================ - --- Returns a snapshot under the `textEditor` key to match the legacy immediate- --- mode restoreState contract (Element:restoreState looked up state.textEditor). --- The behavior-dispatch loop in Element:saveState merges behavior snapshots --- into the top-level state table, so returning { textEditor = ... } slots in --- identically to the old inline `state.textEditor = self._textEditor:getState()` --- assignment. The drag-tracking fields are merged at the top level too --- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred` --- assignments) since they are text-selection state. -local function saveState(element) - local textEditor = element._textEditor - if not textEditor then - -- Non-editable text element: still persist drag-tracking fields if set - -- (they are only ever set for editable elements, but persist defensively). - local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil - if not hasDragState then - return nil - end - local snapshot = {} - if element._mouseDownPosition ~= nil then - snapshot._mouseDownPosition = element._mouseDownPosition - end - if element._textDragOccurred ~= nil then - snapshot._textDragOccurred = element._textDragOccurred - end - return snapshot - end - - local snapshot = { textEditor = textEditor:getState() } - if element._mouseDownPosition ~= nil then - snapshot._mouseDownPosition = element._mouseDownPosition - end - if element._textDragOccurred ~= nil then - snapshot._textDragOccurred = element._textDragOccurred - end - return snapshot -end - --- Consumes the previously-saved snapshot keyed under `textEditor` plus the --- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level --- state table; this hook reads only its own slices, mirroring the legacy --- `if self._textEditor and state.textEditor then ... end` guard. -local function restoreState(element, state) - if not state then - return - end - local textEditor = element._textEditor - if textEditor and state.textEditor then - textEditor:setState(state.textEditor, element) - -- Sync TextEditor's focus/cursor/selection state to Element for theme - -- management (mirrors the legacy restoreState field sync). - element._focused = textEditor._focused - element._cursorPosition = textEditor._cursorPosition - element._selectionStart = textEditor._selectionStart - element._selectionEnd = textEditor._selectionEnd - element._textBuffer = textEditor._textBuffer - end - - -- Restore drag-tracking state for text selection (top-level keys). - if state._mouseDownPosition ~= nil then - element._mouseDownPosition = state._mouseDownPosition - end - if state._textDragOccurred ~= nil then - element._textDragOccurred = state._textDragOccurred - end -end - --- ============================================================================ --- Text-editor delegate functions. --- --- These are the module-level implementations of the 27 text-editor delegate --- methods that previously lived on Element. Each mirrors the pre-refactor --- Element method body VERBATIM (with `self` → `element`), including the --- `element._textEditor` nil-guard: the guard is required because (a) non- --- editable text elements attach this behavior (per shouldAttach) but own no --- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run --- (e.g. an `onCreate` callback firing during _finalizeConstruction, which --- runs before _attachBehaviors). The nil-guards live in THIS file (not in --- Element.lua), so the Element.lua `if self._textEditor` count drops to 0. --- --- Element retains 1-line forwarders: `Element.setText = function(self, text) --- return Element._TextEditable.setText(self, text) end` (etc.), so external --- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged. --- --- The TextEditor API is mixed: most methods take the element as first arg --- (`te:method(element, ...)` — "passesSelf"); a few getters omit it --- (`te:method()`). The delegation contract is pinned by --- subsystem_delegation_test.lua, so this mapping must match TextEditor's --- method signatures exactly. --- ============================================================================ - --- --- Cursor management (passesSelf = element forwarded) ------------------ - -local function setCursorPosition(element, position) - local textEditor = element._textEditor - if textEditor then - textEditor:setCursorPosition(element, position) - end -end - -local function getCursorPosition(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:getCursorPosition() - end - return 0 -end - -local function moveCursorBy(element, delta) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorBy(element, delta) - end -end - -local function moveCursorToStart(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToStart(element) - end -end - -local function moveCursorToEnd(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToEnd(element) - end -end - -local function moveCursorToLineStart(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToLineStart(element) - end -end - -local function moveCursorToLineEnd(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToLineEnd(element) - end -end - -local function moveCursorToPreviousWord(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToPreviousWord(element) - end -end - -local function moveCursorToNextWord(element) - local textEditor = element._textEditor - if textEditor then - textEditor:moveCursorToNextWord(element) - end -end - --- --- Selection management ------------------------------------------------ - -local function setSelection(element, startPos, endPos) - local textEditor = element._textEditor - if textEditor then - textEditor:setSelection(element, startPos, endPos) - end -end - -local function getSelection(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:getSelection() - end - return nil -end - -local function hasSelection(element) - local textEditor = element._textEditor - if textEditor ~= nil then - return textEditor:hasSelection() - end - return false -end - -local function clearSelection(element) - local textEditor = element._textEditor - if textEditor then - textEditor:clearSelection(element) - end -end - -local function selectAll(element) - local textEditor = element._textEditor - if textEditor then - textEditor:selectAll(element) - end -end - -local function getSelectedText(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:getSelectedText() - end - return nil -end - -local function deleteSelection(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:deleteSelection(element) - end - return false -end - --- --- Focus management ---------------------------------------------------- - -local function focus(element) - local textEditor = element._textEditor - if textEditor then - textEditor:focus(element) - end -end - -local function blur(element) - local textEditor = element._textEditor - if textEditor then - textEditor:blur(element) - end -end - -local function isFocused(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:isFocused() - end - return false -end - --- --- Text buffer management (with post-delegation sync) ------------------ --- These methods sync `element.text` from the TextEditor result + drive --- auto-grow, exactly as the legacy Element methods did. - -local function getText(element) - local textEditor = element._textEditor - if textEditor then - return textEditor:getText() - end - return element.text or "" -end - -local function setText(element, text) - local textEditor = element._textEditor - if textEditor then - textEditor:setText(element, text) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - return - end - element.text = text -end - -local function insertText(element, text, position) - local textEditor = element._textEditor - if textEditor then - textEditor:insertText(element, text, position) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - end -end - -local function deleteText(element, startPos, endPos) - local textEditor = element._textEditor - if textEditor then - textEditor:deleteText(element, startPos, endPos) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - end -end - -local function replaceText(element, startPos, endPos, newText) - local textEditor = element._textEditor - if textEditor then - textEditor:replaceText(element, startPos, endPos, newText) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - end -end - --- --- Mouse text selection ------------------------------------------------ - -local function handleTextClick(element, mouseX, mouseY, clickCount) - local textEditor = element._textEditor - if textEditor then - textEditor:handleTextClick(element, mouseX, mouseY, clickCount) - -- Store mouse down position on element for drag tracking - if clickCount == 1 then - element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY) - end - end -end - -local function handleTextDrag(element, mouseX, mouseY) - local textEditor = element._textEditor - if textEditor then - textEditor:handleTextDrag(element, mouseX, mouseY) - element._textDragOccurred = textEditor._textDragOccurred - end -end - --- --- Keyboard input ------------------------------------------------------ - -local function textinput(element, text) - local textEditor = element._textEditor - if textEditor then - textEditor:handleTextInput(element, text) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - end -end - -local function keypressed(element, key, scancode, isrepeat) - local textEditor = element._textEditor - if textEditor then - textEditor:handleKeyPress(element, key, scancode, isrepeat) - element.text = textEditor:getText() -- Sync display text - textEditor:updateAutoGrowHeight(element) - end -end - --- ============================================================================ --- Build the (stateless, shared, immutable) behavior instance + thin module --- table exposing the delegate functions (mirrors the Animated pattern). --- ============================================================================ - -local behavior = Behavior.new({ - onAttach = onAttach, - onDetach = onDetach, - onUpdate = onUpdate, - onDraw = onDraw, - saveState = saveState, - restoreState = restoreState, - shouldAttach = shouldAttach, -}) - --- Thin module table: exposes the frozen behavior instance (for the registry) --- plus the text-editor delegate functions (for Element's 1-line forwarders). --- All hooks delegate to the frozen behavior instance so dispatch sites get --- the validated, frozen implementation. shouldAttach is also exposed at module --- level (mirrors Clickable.shouldAttach) for tests/callers without an element. -local TextEditable = { - behavior = behavior, - shouldAttach = shouldAttach, - onAttach = onAttach, - onUpdate = onUpdate, - onDraw = onDraw, - saveState = saveState, - restoreState = restoreState, - -- Text-editor delegate functions (Element forwarders route through these): - setCursorPosition = setCursorPosition, - getCursorPosition = getCursorPosition, - moveCursorBy = moveCursorBy, - moveCursorToStart = moveCursorToStart, - moveCursorToEnd = moveCursorToEnd, - moveCursorToLineStart = moveCursorToLineStart, - moveCursorToLineEnd = moveCursorToLineEnd, - moveCursorToPreviousWord = moveCursorToPreviousWord, - moveCursorToNextWord = moveCursorToNextWord, - setSelection = setSelection, - getSelection = getSelection, - hasSelection = hasSelection, - clearSelection = clearSelection, - selectAll = selectAll, - getSelectedText = getSelectedText, - deleteSelection = deleteSelection, - focus = focus, - blur = blur, - isFocused = isFocused, - getText = getText, - setText = setText, - insertText = insertText, - deleteText = deleteText, - replaceText = replaceText, - _handleTextClick = handleTextClick, - _handleTextDrag = handleTextDrag, - textinput = textinput, - keypressed = keypressed, -} - --- Metatable so the module table itself satisfies the duck-typed registry --- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach` --- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the --- frozen behavior instance for every hook / isBehavior parity. -setmetatable(TextEditable, { - __index = behavior, - __tostring = function() - return "TextEditable" - end, -}) - -return TextEditable diff --git a/libs/flexlove/modules/behaviors/Themed.lua b/libs/flexlove/modules/behaviors/Themed.lua deleted file mode 100644 index bd431331..00000000 --- a/libs/flexlove/modules/behaviors/Themed.lua +++ /dev/null @@ -1,178 +0,0 @@ --- modules/behaviors/Themed.lua --- --- Concrete behavior: Renderer ownership + theme-state rendering. --- --- Themed owns the per-element Renderer instance and the single --- `Renderer:draw` call that paints the core visual layers (background, image, --- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification --- replacement for the former `_initImageAndRenderer` Renderer creation block and --- the former first `self._renderer:draw(self, backdropCanvas)` call in --- Element:draw (behavior-mode-unification task 07). --- --- Attachment rule (shouldAttach): every renderable Element. The pre-refactor --- code unconditionally created a Renderer for every Element and unconditionally --- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the --- Renderer is always available to subsystems that depend on it (TextEditor font --- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of --- background / border / theme / image layers is preserved for every element. --- Restricting attachment to `themeComponent`-only elements would break editable --- text fields and scrollable containers (which need a Renderer for subsystem --- delegation even when they have no theme component). The 9-patch theme-state --- rendering within `Renderer:draw` is a no-op for elements without a --- `themeComponent`, so always-attaching carries no rendering cost. --- --- Themed and Imageable are paired (both configure the same `element._renderer`): --- Themed.onAttach creates the Renderer with the theme/blur config; Imageable --- (attached for imagePath/image elements) enriches the SAME renderer instance with --- image config + deferred image loading. They share `element._renderer`. --- --- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable --- behavior (whose onUpdate recomputes hover/press/focus and calls --- `renderer:setThemeState`). Themed only READS that state for rendering, so it has --- no per-frame update work. --- --- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur --- geometry + radius/quality used by the Blur cache for invalidation (formerly --- the inline `if self.backdropBlur or self.contentBlur` block of --- Element:saveState — behavior-mode-unification task 12). restoreState is a --- no-op: blur cache data is used for invalidation, not restoration (the Blur --- cache is keyed by element id and cleared via `Blur.clearElementCache` from --- FlexLove.endFrame, not replayed through restoreState). --- --- State ownership (per the locked Behavior contract): --- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`, --- `element._themeState`, `element.backdropBlur`, `element.contentBlur`). --- The behavior instance is stateless and shared. --- * `element._renderer` is recreated on attach; onDetach is a no-op — the --- reference is released when the element is GC'd (Element:_cleanup keeps --- element structure for inspection). - -local _pkg = (...):match("^(.-)behaviors%.") or "modules." -local Behavior = require(_pkg .. "Behavior") - --- Resolve the Element class from an element instance (mirrors Clickable). --- Element instances are created via `setmetatable({}, Element)`, so their --- metatable IS the Element class — giving access to Element._Renderer, --- Element._rendererDeps, etc. without threading deps through the hook signature. -local function ElementClass(element) - return getmetatable(element) -end - --- ---------------------------------------------------------------------------- --- shouldAttach (class-level predicate, no element required) --- ---------------------------------------------------------------------------- - --- Returns true for every renderable Element. See file header for the rationale: --- the pre-refactor invariant was "every Element has a Renderer; Element:draw --- always calls Renderer:draw", and Thamed is the behavior-system embodiment of --- that invariant. Returns true for `themeComponent`-bearing props (the spec's --- headline case) and for every other element so subsystems/rendering stay intact. -local function shouldAttach(props) - return true -end - --- ---------------------------------------------------------------------------- --- onAttach — create the Renderer with theme/blur config (formerly the --- Renderer.new block of Element:_initImageAndRenderer). --- ---------------------------------------------------------------------------- - -local function onAttach(element) - local Element = ElementClass(element) - - -- Create-or-reuse the Renderer. Thamed is the first render behavior in the - -- registry, so it normally creates the instance; Imageable (if attached) will - -- reuse this same instance for image config. Guarded so Imageable-onAttach- - -- first (defensive) does not clobber an existing renderer. - if element._renderer then - return - end - - -- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are - -- intentionally NOT passed here. Renderer:draw() reads them from the element - -- as the single source of truth (see Renderer.lua draw()). Only renderer-owned - -- state (theme, blur) is cached on the renderer; image config is added by the - -- Imageable behavior. border is element-sourced too. - element._renderer = Element._Renderer.new({ - theme = element.theme, - scaleCorners = element.scaleCorners, - scalingAlgorithm = element.scalingAlgorithm, - contentBlur = element.contentBlur, - backdropBlur = element.backdropBlur, - }, Element._rendererDeps) -end - --- ---------------------------------------------------------------------------- --- onDraw — the single Renderer:draw call (formerly the first call in --- Element:draw). Paints all core visual layers for this element. --- ---------------------------------------------------------------------------- - -local function onDraw(element, ctx) - local renderer = element._renderer - if not renderer then - return - end - renderer:draw(element, ctx and ctx.backdropCanvas) -end - --- ---------------------------------------------------------------------------- --- onDetach — no-op. Element:_cleanup preserves element structure for --- inspection (the original invariant), so the Renderer reference is released --- when the element is GC'd rather than torn down here. Present as an explicit --- hook so the behavior conforms to the full lifecycle contract. --- ---------------------------------------------------------------------------- - -local function onDetach() end - --- ---------------------------------------------------------------------------- --- saveState — blur-region snapshot (formerly the `blur` branch of --- Element:saveState). Returns `{ blur = {...} }` when the element configures a --- backdrop or content blur, so the Blur cache can invalidate by element id; --- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is --- only read back by the cache-invalidation path, which itself is --- immediate-mode-only via FlexLove.endFrame). --- ---------------------------------------------------------------------------- - -local function saveState(element) - if not (element.backdropBlur or element.contentBlur) then - return nil - end - local blur = { - _blurX = element.x, - _blurY = element.y, - _blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right), - _blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom), - } - if element.backdropBlur then - blur._backdropBlurRadius = element.backdropBlur.radius - blur._backdropBlurQuality = element.backdropBlur.quality or 5 - end - if element.contentBlur then - blur._contentBlurRadius = element.contentBlur.radius - blur._contentBlurQuality = element.contentBlur.quality or 5 - end - return { blur = blur } -end - --- restoreState — no-op: blur cache data is used for invalidation, not --- restoration (see file header). Present so the behavior conforms to the --- lifecycle contract without replaying geometry that the cache recomputes. - --- ---------------------------------------------------------------------------- --- Build the (stateless, shared, immutable) behavior instance. --- ---------------------------------------------------------------------------- - -local Themed = Behavior.new({ - onAttach = onAttach, - onDetach = onDetach, - onUpdate = function() end, - onDraw = onDraw, - saveState = saveState, - restoreState = function() end, -}) - --- Expose the predicate at module level so callers/tests can reference it --- directly without an element instance (mirrors Behavior.shouldAttach / --- Clickable.shouldAttach). -Themed.shouldAttach = shouldAttach - -return Themed diff --git a/libs/flexlove/modules/types.lua b/libs/flexlove/modules/types.lua deleted file mode 100644 index dd504797..00000000 --- a/libs/flexlove/modules/types.lua +++ /dev/null @@ -1,662 +0,0 @@ ----@class SelectOptionProps ----@field value any -- Stable option value owned by the parent select ----@field label string? -- Optional label override, falls back to the element text ----@field disabled boolean? -- Whether the option can be selected -local SelectOptionProps = {} - ----@class SelectParentProps ----@field value any -- Currently selected option value ----@field open boolean? -- Initial open state for the select container ----@field placeholder string? -- Fallback text when no option is selected ----@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select ----@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes -local SelectParentProps = {} - ----@class Animation -local Animation = {} - ----@class Color -local Color = {} - ----@class Theme -local Theme = {} - ----@class ThemeManager -local ThemeManager = {} - ---=====================================-- --- For Animation.lua ---=====================================-- ----@alias EasingFunction fun(t:number): number - ----@class AnimationProps ----@field duration number -- Duration in seconds ----@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.) ----@field final table -- Final values (same properties as start) ----@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear") ----@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations ----@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts ----@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1) ----@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes ----@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled ----@field transform TransformProps? -- Additional transform properties (legacy support) ----@field transition table? -- Transition properties (legacy support) -local AnimationProps = {} - ----@class Transform ----@field rotate number? Rotation in radians (default: 0) ----@field scaleX number? X-axis scale (default: 1) ----@field scaleY number? Y-axis scale (default: 1) ----@field translateX number? X translation in pixels (default: 0) ----@field translateY number? Y translation in pixels (default: 0) ----@field skewX number? X-axis skew in radians (default: 0) ----@field skewY number? Y-axis skew in radians (default: 0) ----@field originX number? Transform origin X (0-1, default: 0.5) ----@field originY number? Transform origin Y (0-1, default: 0.5) -local Transform = {} - ----@alias TransformProps Transform - ----@class TransitionProps ----@field duration number? ----@field easing string? ----@field delay number? ----@field onComplete fun(element:Element)? - ---=====================================-- --- For Element.lua ---=====================================-- ----@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) ----@field z number? -- Z-index for layering (default: 0, clamped to -999..999) ----@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation) ----@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically) ----@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically) ----@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal. ----@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal. ----@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical. ----@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical. ----@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning) ----@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning) ----@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning) ----@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning) ----@field border Border? -- Border configuration for the element ----@field borderColor Color? -- Color of the border (default: black) ----@field opacity number? -- Element opacity 0-1 (default: 1) ----@field visibility "visible"|"hidden"? -- Element visibility (default: "visible") ----@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout. ----@field backgroundColor Color? -- Background color (default: transparent) ----@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0) ----@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) ----@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0}) ----@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0}) ----@field text string? -- Text content to display (default: nil) ----@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START) ----@field textColor Color? -- Color of the text content (default: black or theme text color) ----@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px) ----@field minTextSize number? -- Minimum text size in pixels for auto-scaling ----@field maxTextSize number? -- Maximum text size in pixels for auto-scaling ----@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent) ----@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true) ----@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE) ----@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL) ----@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START) ----@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH) ----@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH) ----@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP) ----@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil) ----@field flexGrow number? -- How much the element should grow relative to siblings (default: 0) ----@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1) ----@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto") ----@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO) ----@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO) ----@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events ----@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false) ----@field onFocus fun(element:Element)? -- Callback when element receives focus ----@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false) ----@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection) ----@field onBlur fun(element:Element)? -- Callback when element loses focus ----@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false) ----@field onTextInput fun(element:Element, text:string)? -- Callback when text is input ----@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false) ----@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes ----@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false) ----@field onEnter fun(element:Element)? -- Callback when Enter key is pressed ----@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false) ----@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props ----@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false) ----@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease) ----@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false) ----@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.) ----@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false) ----@field touchEnabled boolean? -- Whether the element responds to touch events (default: true) ----@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false) ----@field transform TransformProps? -- Transform properties for animations and styling ----@field transition TransitionProps? -- Transition settings for animations ----@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil) ----@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"}) ----@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"}) ----@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) ----@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0) ----@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init() ----@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied ----@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, or true when using themeComponent) ----@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false) ----@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires. ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1}) ----@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting) ----@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) ----@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5) ----@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5) ----@field editable boolean? -- Whether the element is editable (default: false) ----@field multiline boolean? -- Whether the element supports multiple lines (default: false) ----@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line) ----@field maxLines number? -- Maximum number of lines (default: nil) ----@field maxLength number? -- Maximum text length in characters (default: nil) ----@field placeholder string? -- Placeholder text when empty (default: nil) ----@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline) ----@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text") ----@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip") ----@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line) ----@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line) ----@field selectOnFocus boolean? -- Whether to select all text on focus (default: false) ----@field cursorColor Color? -- Cursor color (default: nil, uses textColor) ----@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) ----@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5) ----@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks ----@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent ----@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden") ----@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow) ----@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow) ----@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12) ----@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8)) ----@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5)) ----@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6) ----@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) ----@field scrollSpeed number? -- Pixels per wheel notch (default: 20) ----@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false) ----@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false) ----@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering) ----@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset) ----@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content) ----@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false) ----@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false) ----@field imagePath string? -- Path to image file (auto-loads via ImageCache) ----@field image love.Image? -- Image object to display ----@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill") ----@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center") ----@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity) ----@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat") ----@field imageTint Color? -- Color to tint the image (default: nil/white, no tint) ----@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully ----@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false) ----@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load ----@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false) ----@field _scrollX number? -- Internal: scroll X position (restored in immediate mode) ----@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode) ----@field children? ElementProps[] ----@field userdata table? -- User-defined data storage for custom properties ----@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog") ----@field ariaLabel string? -- Accessible name for screen readers (overrides text content) ----@field ariaDescribedBy string? -- ID of element that describes this element ----@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers) ----@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons) ----@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios) ----@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property) ----@field ariaBusy boolean? -- Whether element is processing (for live regions) ----@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements -local ElementProps = {} - ----@class Border ----@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) ----@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) ----@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) ----@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0) -local Border = {} - ---=====================================-- --- For KeyboardNavigation.lua ---=====================================-- ----@class KeyboardNavigationKeyConfig ----@field next string -- Key used to move to the next focusable element ----@field previous string -- Key used to move to the previous focusable element ----@field up string -- Key used for directional navigation upward ----@field down string -- Key used for directional navigation downward ----@field left string -- Key used for directional navigation leftward ----@field right string -- Key used for directional navigation rightward ----@field activate string[] -- Keys that activate the currently focused element ----@field dismiss string -- Key used to dismiss or clear the currently focused element ----@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling ----@field inspect string -- Key used to inspect the currently focused element in developer tools -local KeyboardNavigationKeyConfig = {} - ----@class KeyboardNavigationDeveloperToolsConfig ----@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true) ----@field showProperties boolean? -- Show focused element properties in developer tools (default: true) ----@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5}) -local KeyboardNavigationDeveloperToolsConfig = {} - ----@class KeyboardNavigationFocusIndicatorConfig ----@field enabled boolean? -- Enable the keyboard focus indicator (default: true) ----@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8}) ----@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2) ----@field inset number? -- Offset from the element bounds in pixels (default: -3) ----@field borderRadius number? -- Focus indicator border radius in pixels (default: 4) ----@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15) ----@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported ----@field pulseDuration number? -- Seconds per pulse cycle ----@field pulseScaleMin number? -- Minimum scale during pulse animation ----@field pulseScaleMax number? -- Maximum scale during pulse animation ----@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer -local KeyboardNavigationFocusIndicatorConfig = {} - ----@class KeyboardNavigationConfig ----@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true) ----@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false) ----@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation ----@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true) ----@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true) ----@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true) ----@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false) ----@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true) ----@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation ----@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration -local KeyboardNavigationConfig = {} - ---=====================================-- --- For FlexLove.init() ---=====================================-- ----@class FlexLoveConfig ----@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling) ----@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme) ----@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false) ----@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false) ----@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60) ----@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000) ----@field includeStackTrace boolean? -- Include stack traces in error messages (default: true) ----@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn) ----@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console") ----@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log") ----@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB) ----@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5) ----@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true) ----@field performanceMonitoring boolean? -- Enable performance monitoring (default: true) ----@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3") ----@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10}) ----@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0) ----@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67) ----@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false) ----@field performanceWarnings boolean? -- Enable performance warnings (default: false) ----@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode) ----@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto") ----@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100) ----@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60) ----@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) ----@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides ----@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false) ----@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key) -local FlexLoveConfig = {} - ---=====================================-- --- Public FlexLove API ---=====================================-- ----@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right" ----@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical} - ----@class FlexLoveEnums ----@field TextAlign TextAlign ----@field TextAlignVertical TextAlignVertical ----@field Positioning Positioning ----@field FlexDirection FlexDirection ----@field JustifyContent JustifyContent ----@field JustifySelf JustifySelf ----@field AlignItems AlignItems ----@field AlignSelf AlignSelf ----@field AlignContent AlignContent ----@field FlexWrap FlexWrap ----@field TextSize TextSize ----@field ImageRepeat ImageRepeat ----@field ARIA ARIA -local FlexLoveEnums = {} - ----@class AnimationKeyframe ----@field at number -- Normalized time position (0-1) ----@field values table -- Property values at this keyframe ----@field easing string|EasingFunction? -- Easing used between this and the next keyframe -local AnimationKeyframe = {} - ----@class AnimationGroupProps ----@field animations Animation[] -- Animations to coordinate ----@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel") ----@field stagger number? -- Delay between staggered animations in seconds (default: 0.1) ----@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete ----@field onStart fun(group:AnimationGroup)? -- Called when the group starts -local AnimationGroupProps = {} - ----@class AnimationGroup ----@field animations Animation[] ----@field mode "parallel"|"sequence"|"stagger" ----@field stagger number ----@field onComplete fun(group:AnimationGroup)? ----@field onStart fun(group:AnimationGroup)? -local AnimationGroup = {} - ----@class Animation ----@field duration number ----@field start table ----@field final table ----@field elapsed number ----@field easing EasingFunction ----@field keyframes AnimationKeyframe[]? ----@field transform TransformProps? ----@field transition TransitionProps? ----@field onStart fun(animation:Animation, element:Element?)? ----@field onUpdate fun(animation:Animation, element:Element?, progress:number)? ----@field onComplete fun(animation:Animation, element:Element?)? ----@field onCancel fun(animation:Animation, element:Element?)? ----@field update fun(self:Animation, dt:number, element:table?): boolean ----@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe? ----@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table ----@field interpolate fun(self:Animation): table ----@field apply fun(self:Animation, element:table) ----@field pause fun(self:Animation) ----@field resume fun(self:Animation) ----@field isPaused fun(self:Animation): boolean ----@field reverse fun(self:Animation) ----@field isReversed fun(self:Animation): boolean ----@field setSpeed fun(self:Animation, speed:number) ----@field getSpeed fun(self:Animation): number ----@field seek fun(self:Animation, time:number) ----@field getState fun(self:Animation): string ----@field cancel fun(self:Animation, element:table?) ----@field reset fun(self:Animation) ----@field getProgress fun(self:Animation): number ----@field chain fun(self:Animation, nextAnimation:Animation|function): Animation ----@field delay fun(self:Animation, seconds:number): Animation ----@field repeatCount fun(self:Animation, count:number): Animation ----@field yoyo fun(self:Animation, enabled:boolean?): Animation ----@class AnimationModule ----@field Easing table -- Built-in easing functions and easing factories ----@field Transform table? -- Animation transform helpers exposed by the animation module ----@field Group AnimationGroup -- Animation group class table ----@field new fun(props:AnimationProps): Animation ----@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation ----@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation ----@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation ----@field chainSequence fun(animations:Animation[]): Animation -local AnimationModule = {} - ----@class ColorInputTable ----@field [1] number? ----@field [2] number? ----@field [3] number? ----@field [4] number? ----@field r number? ----@field g number? ----@field b number? ----@field a number? -local ColorInputTable = {} - ----@alias ColorInput string|Color|ColorInputTable - ----@class ColorModule ----@field new fun(r:number?, g:number?, b:number?, a:number?): Color ----@field fromHex fun(hexWithTag:string): Color ----@field validateColorChannel fun(value:any, max:number?): boolean, number? ----@field validateHexColor fun(hex:string): boolean, string? ----@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string? ----@field isValidColorFormat fun(value:any): string? ----@field sanitizeColor fun(value:any, default:Color?): Color ----@field parse fun(value:any): Color ----@field lerp fun(colorA:Color, colorB:Color, t:number): Color -local ColorModule = {} - ----@class ThemeManagerConfig ----@field theme string? -- Theme name override ----@field themeComponent string? -- Component name to resolve from the theme ----@field disabled boolean? -- Force disabled theme state ----@field active boolean? -- Force active theme state ----@field disableHighlight boolean? -- Disable pressed highlight overlay ----@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state ----@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually ----@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges ----@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions -local ThemeManagerConfig = {} - ----@class ThemeRegion ----@field x number ----@field y number ----@field w number ----@field h number -local ThemeRegion = {} - ----@class ThemeComponent ----@field atlas string|love.Image? ----@field insets {left:number, top:number, right:number, bottom:number}? ----@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}? ----@field stretch {horizontal:table, vertical:table}? ----@field states table? ----@field contentAutoSizingMultiplier {width:number?, height:number?}? ----@field scaleCorners number? ----@field scalingAlgorithm "nearest"|"bilinear"? ----@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -local ThemeComponent = {} - ----@class ThemeDefinition ----@field name string ----@field atlas string|love.Image? ----@field components table ----@field scrollbars table? ----@field colors table? ----@field fonts table? ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -local ThemeDefinition = {} - ----@class Theme ----@field name string ----@field atlas love.Image? ----@field atlasData love.ImageData? ----@field components table ----@field scrollbars table ----@field colors table ----@field fonts table ----@field contentAutoSizingMultiplier {width:number?, height:number?}? ----@class ThemeManager ----@field theme string? ----@field themeComponent string? ----@field disabled boolean ----@field active boolean ----@field disableHighlight boolean? ----@field themeStateLock boolean|string? ----@field themeComponentDisabledStates table ----@field scaleCorners number? ----@field scalingAlgorithm "nearest"|"bilinear"? ----@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string ----@field getState fun(self:ThemeManager): string ----@field setState fun(self:ThemeManager, state:string) ----@field hasThemeComponent fun(self:ThemeManager): boolean ----@field getTheme fun(self:ThemeManager): Theme? ----@field getComponent fun(self:ThemeManager): ThemeComponent? ----@field getStateComponent fun(self:ThemeManager): ThemeComponent? ----@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent? ----@field getStyle fun(self:ThemeManager, property:string): any? ----@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? ----@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding ----@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table? ----@field getContentAutoSizingMultiplier fun(self:ThemeManager): table? ----@field getDefaultFontFamily fun(self:ThemeManager): string? ----@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?) ----@field validateThemeStateLock fun(self:ThemeManager): boolean ----@class Color ----@field r number ----@field g number ----@field b number ----@field a number ----@field toRGBA fun(self:Color): number, number, number, number ----@class ThemeModule ----@field Manager ThemeManager -- Theme manager class table ----@field new fun(definition:ThemeDefinition): Theme ----@field load fun(path:string): Theme? ----@field setActive fun(themeOrName:string|Theme) ----@field getActive fun(): Theme? ----@field getComponent fun(componentName:string, state:string?): ThemeComponent? ----@field getDefaultScrollbar fun(): ThemeComponent? ----@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent? ----@field getFont fun(fontName:string): string? ----@field getColor fun(colorName:string): Color? ----@field hasActive fun(): boolean ----@field getRegisteredThemes fun(): table ----@field getColorNames fun(): string[] ----@field getAllColors fun(): table ----@field getColorOrDefault fun(colorName:string, fallback:Color): Color ----@field get fun(themeName:string): Theme? ----@field validateTheme fun(theme:table?, options:table?): boolean, table ----@field sanitizeTheme fun(theme:table?): table -local ThemeModule = {} - ----@class FlexLove ----@field _VERSION string ----@field _DESCRIPTION string ----@field _URL string ----@field _LICENSE string ----@field Animation AnimationModule? ----@field Color ColorModule ----@field Theme ThemeModule? ----@field enums FlexLoveEnums ----@field isReady fun(): boolean ----@field init fun(config:FlexLoveConfig?) ----@field setKeyboardNavigationDebug fun(enabled:boolean) ----@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?) ----@field deferCallback fun(callback:function) ----@field executeDeferredCallbacks fun() ----@field resize fun() ----@field setMode fun(mode:"immediate"|"retained") ----@field getMode fun(): "immediate"|"retained" ----@field beginFrame fun() ----@field endFrame fun() ----@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil) ----@field getElementAtPosition fun(x:number, y:number): Element? ----@field update fun(dt:number) ----@field collectGarbage fun(mode:string?, stepSize:number?): number? ----@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled") ----@field getGCStats fun(): GCStats ----@field textinput fun(text:string) ----@field keypressed fun(key:string, scancode:string, isrepeat:boolean) ----@field wheelmoved fun(dx:number, dy:number) ----@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) ----@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) ----@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number) ----@field getActiveTouchCount fun(): number ----@field getTouchOwner fun(touchId:string): Element? ----@field getById fun(id:string): Element? ----@field destroy fun() ----@field new fun(props:ElementProps, callback:function?): Element? ----@field getStateCount fun(): number ----@field clearState fun(id:string) ----@field clearAllStates fun() ----@field getStateStats fun(): table ----@field calc fun(expr:string): CalcObject ----@field getFocusedElement fun(): Element? ----@field setFocusedElement fun(element:Element?) ----@field clearFocus fun() ----@field setDebugDraw fun(enabled:boolean) ----@field getDebugDraw fun(): boolean -local FlexLove = {} - ---=====================================-- --- 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 _backdropBlurRadius number? ----@field _backdropBlurQuality number? ----@field _contentBlurRadius number? ----@field _contentBlurQuality number? - ---=====================================-- --- For Calc.lua ---=====================================-- ----@class CalcDependencies ----@field ErrorHandler ErrorHandler? -- Error handler module - ----@class CalcToken ----@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF" ----@field value number? -- Numeric value (for NUMBER tokens) ----@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens) - ----@class CalcASTNode ----@field type string -- Node type: "number", "add", "subtract", "multiply", "divide" ----@field value number? -- Numeric value (for "number" nodes) ----@field unit string? -- Unit type (for "number" nodes) ----@field left CalcASTNode? -- Left operand (for operator nodes) ----@field right CalcASTNode? -- Right operand (for operator nodes) - ----@class CalcObject ----@field _isCalc boolean -- Marker to identify calc objects (always true) ----@field _expr string -- Original expression string ----@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed) ----@field _error string? -- Error message if parsing failed - ---=====================================-- --- For FlexLove.lua Internals ---=====================================-- ----@class GCConfig ----@field strategy string -- "auto", "periodic", "manual", or "disabled" ----@field memoryThreshold number -- MB before forcing GC ----@field interval number -- Frames between GC steps (for periodic mode) ----@field stepSize number -- Work units per GC step (higher = more aggressive) - ----@class GCState ----@field framesSinceLastGC number -- Frames elapsed since last GC ----@field lastMemory number -- Last recorded memory usage in MB ----@field gcCount number -- Total number of GC operations performed - ----@class GCStats ----@field gcCount number -- Total number of GC operations performed ----@field framesSinceLastGC number -- Frames elapsed since last GC ----@field currentMemoryMB number -- Current memory usage in MB ----@field strategy string -- Current GC strategy ----@field threshold number -- Memory threshold in MB - ----@class FlexLoveDependencies ----@field Context table -- Context module ----@field Theme Theme? -- Theme module ----@field Color Color -- Color module ----@field Calc Calc -- Calc module ----@field Units table -- Units module ----@field Blur table? -- Blur module ----@field ImageRenderer table? -- ImageRenderer module ----@field ImageScaler table? -- ImageScaler module ----@field NinePatch table? -- NinePatch module ----@field RoundedRect table -- RoundedRect module ----@field ImageCache table? -- ImageCache module ----@field utils table -- Utils module ----@field Grid table -- Grid module ----@field InputEvent table -- InputEvent module ----@field GestureRecognizer table? -- GestureRecognizer module ----@field StateManager StateManager -- StateManager module ----@field TextEditor table -- TextEditor module ----@field LayoutEngine LayoutEngine -- LayoutEngine module ----@field Renderer table -- Renderer module ----@field EventHandler EventHandler -- EventHandler module ----@field ScrollManager table -- ScrollManager module ----@field ErrorHandler ErrorHandler -- ErrorHandler module ----@field Performance Performance? -- Performance module ----@field Transform table? -- Transform module diff --git a/libs/flexlove/modules/utils.lua b/libs/flexlove/modules/utils.lua deleted file mode 100644 index 71a074c0..00000000 --- a/libs/flexlove/modules/utils.lua +++ /dev/null @@ -1,319 +0,0 @@ -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Focused sub-modules (utils now re-exports their surfaces as backward-compatible --- aliases so call sites needn't change). Loaded eagerly so the aliases resolve. -local NumberValidation = req("NumberValidation") -local TextSanitizer = req("TextSanitizer") -local PathValidator = req("PathValidator") -local FontCache = req("FontCache") -local Enums = req("Enums") - --- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue). -local ErrorHandler = nil - -local enums = Enums.enums - --- Generic math, table, and path helpers (utils' own concern). --- All validation, font-cache, text-sanitization, and path-validation logic --- lives in the focused sub-modules above and is re-exported below. - ---- Get current keyboard modifiers state ----@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} -local function getModifiers() - return { - shift = love.keyboard.isDown("lshift", "rshift"), - ctrl = love.keyboard.isDown("lctrl", "rctrl"), - alt = love.keyboard.isDown("lalt", "ralt"), - ---@diagnostic disable-next-line - super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key - } -end - -local TEXT_SIZE_PRESETS = { - ["2xs"] = 0.75, - xxs = 0.75, - xs = 1.25, - sm = 1.75, - md = 2.25, - lg = 2.75, - xl = 3.5, - xxl = 4.5, - ["2xl"] = 4.5, - ["3xl"] = 5.0, - ["4xl"] = 7.0, -} - ---- Resolve text size preset to viewport units ----@param sizeValue string|number ----@return number?, string? -local function resolveTextSizePreset(sizeValue) - if type(sizeValue) == "string" then - local preset = TEXT_SIZE_PRESETS[sizeValue] - if preset then - return preset, "vh" - end - end - return nil, nil -end - ---- Auto-detect the base path where FlexLove is located ----@return string filesystemPath -local function getFlexLoveBasePath() - local info = debug.getinfo(1, "S") - if info and info.source then - local source = info.source - if source:sub(1, 1) == "@" then - source = source:sub(2) - end - - local filesystemPath = source:match("(.*/)") - if filesystemPath then - local fsPath = filesystemPath - fsPath = fsPath:gsub("^%./", "") - fsPath = fsPath:gsub("/$", "") - fsPath = fsPath:gsub("/modules$", "") - return fsPath - end - end - return "libs" -end - -local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() - ---- Helper function to resolve paths relative to FlexLove ----@param path string ----@return string -local function resolveImagePath(path) - if path:match("^/") or path:match("^[A-Z]:") then - return path - end - return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path -end - --- Math utilities - ---- Clamp a value between optional min/max bounds. Either bound may be nil. ---- When both bounds are inverted (min > max), max wins (matches CSS behavior). ----@param value number Value to clamp ----@param min number|nil Minimum value (nil = no lower bound) ----@param max number|nil Maximum value (nil = no upper bound) ----@return number Clamped value -local function clamp(value, min, max) - if min and value < min then - value = min - end - if max and value > max then - value = max - end - return value -end - ---- Linear interpolation between two values ----@param a number Start value ----@param b number End value ----@param t number Interpolation factor (0-1) ----@return number Interpolated value -local function lerp(a, b, t) - return a + (b - a) * t -end - ---- Round a number to the nearest integer ----@param value number Value to round ----@return number Rounded value -local function round(value) - return math.floor(value + 0.5) -end - --- Image utilities - ---- Safely load an image with error handling ---- Returns both Image and ImageData to avoid deprecated getData() API ----@param imagePath string Path to image file ----@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message -local function safeLoadImage(imagePath) - local success, imageData = pcall(function() - return love.image.newImageData(imagePath) - end) - - if not success then - local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData)) - if ErrorHandler then - ErrorHandler:warn("utils", "RES_004", { - resourceType = "image data", - path = imagePath, - error = tostring(imageData), - }) - end - return nil, nil, errorMsg - end - - local imageSuccess, image = pcall(function() - return love.graphics.newImage(imageData) - end) - - if imageSuccess then - return image, imageData, nil - else - local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image)) - if ErrorHandler then - ErrorHandler:warn("utils", "RES_004", { - resourceType = "image", - path = imagePath, - error = tostring(image), - }) - end - return nil, nil, errorMsg - end -end - --- Color manipulation utilities - ---- Brighten a color by a factor ----@param r number Red component (0-1) ----@param g number Green component (0-1) ----@param b number Blue component (0-1) ----@param a number Alpha component (0-1) ----@param factor number Brightness factor (e.g., 1.2 for 20% brighter) ----@return number, number, number, number Brightened color components -local function brightenColor(r, g, b, a, factor) - return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a -end - --- Property normalization utilities - ---- Normalize a boolean or table property with vertical/horizontal fields ----@param value boolean|table|nil Input value (boolean applies to both, table for individual control) ----@param defaultValue boolean Default value if nil (default: false) ----@return table Normalized table with vertical and horizontal fields -local function normalizeBooleanTable(value, defaultValue) - defaultValue = defaultValue or false - - if value == nil then - return { vertical = defaultValue, horizontal = defaultValue } - end - - if type(value) == "boolean" then - return { vertical = value, horizontal = value } - end - - if type(value) == "table" then - return { - vertical = value.vertical ~= nil and value.vertical or defaultValue, - horizontal = value.horizontal ~= nil and value.horizontal or defaultValue, - } - end - - return { vertical = defaultValue, horizontal = defaultValue } -end - ---- Normalize an offset value to {x, y} or {horizontal, vertical} format ----@param value number|table|nil Input value (number applies to both, table for individual control) ----@param defaultValue number Default value if nil (default: 0) ----@return table Normalized table with x/y or horizontal/vertical fields -local function normalizeOffsetTable(value, defaultValue) - defaultValue = defaultValue or 0 - - if value == nil then - return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } - end - - if type(value) == "number" then - return { x = value, y = value, horizontal = value, vertical = value } - end - - if type(value) == "table" then - -- Support both {x, y} and {horizontal, vertical} formats - local x = value.x or value.horizontal or defaultValue - local y = value.y or value.vertical or defaultValue - return { - x = x, - y = y, - horizontal = x, - vertical = y, - } - end - - return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue } -end - ---- Apply content auto-sizing multiplier to a dimension ----@param value number The dimension value ----@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?} ----@param axis "width"|"height" Which axis to apply ----@return number The multiplied value -local function applyContentMultiplier(value, multiplier, axis) - if multiplier and multiplier[axis] then - return value * multiplier[axis] - end - return value -end - ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler } -local function init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - end - -- Propagate shared ErrorHandler to focused sub-modules that need it. - NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp }) - TextSanitizer.init({ ErrorHandler = ErrorHandler }) - FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath }) - -- PathValidator has no external dependencies. -end - -return { - enums = enums, - FONT_CACHE = FontCache.FONT_CACHE, - resolveTextSizePreset = resolveTextSizePreset, - getModifiers = getModifiers, - TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS, - init = init, - clamp = clamp, - -- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine - -- and tests can reference min/max content-size clamping explicitly. - clampSize = clamp, - lerp = lerp, - round = round, - safeLoadImage = safeLoadImage, - brightenColor = brightenColor, - resolveImagePath = resolveImagePath, - normalizeBooleanTable = normalizeBooleanTable, - normalizeOffsetTable = normalizeOffsetTable, - applyContentMultiplier = applyContentMultiplier, - -- Backward-compatible aliases (delegated to focused sub-modules) - validateEnum = NumberValidation.validateEnum, - validateRange = NumberValidation.validateRange, - validateType = NumberValidation.validateType, - isNaN = NumberValidation.isNaN, - isInfinity = NumberValidation.isInfinity, - validateNumber = NumberValidation.validateNumber, - sanitizeNumber = NumberValidation.sanitizeNumber, - validateInteger = NumberValidation.validateInteger, - validatePercentage = NumberValidation.validatePercentage, - validateOpacity = NumberValidation.validateOpacity, - validateDegrees = NumberValidation.validateDegrees, - validateCoordinate = NumberValidation.validateCoordinate, - validateDimension = NumberValidation.validateDimension, - normalizePath = PathValidator.normalizePath, - sanitizePath = PathValidator.sanitizePath, - isPathSafe = PathValidator.isPathSafe, - validatePath = PathValidator.validatePath, - getFileExtension = PathValidator.getFileExtension, - hasAllowedExtension = PathValidator.hasAllowedExtension, - sanitizeText = TextSanitizer.sanitizeText, - validateTextInput = TextSanitizer.validateTextInput, - validateTextRange = TextSanitizer.validateTextRange, - escapeHtml = TextSanitizer.escapeHtml, - escapeLuaPattern = TextSanitizer.escapeLuaPattern, - stripNonPrintable = TextSanitizer.stripNonPrintable, - resolveFontPath = FontCache.resolveFontPath, - getFont = FontCache.getFont, - getFontCacheStats = FontCache.getFontCacheStats, - setFontCacheSize = FontCache.setFontCacheSize, - clearFontCache = FontCache.clearFontCache, - preloadFont = FontCache.preloadFont, - resetFontCacheStats = FontCache.resetFontCacheStats, -} diff --git a/main.lua b/main.lua index 07f98b45..4dfb2fa1 100644 --- a/main.lua +++ b/main.lua @@ -11,6 +11,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") +local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") -- Lua errors: persist a redacted trace in the save dir and surface a hint. @@ -325,6 +326,26 @@ function love.load(args) if preload then require("src.core.Strings").load({ strings = preload }) end end + -- LAUNCH OPTIONS: skip the launcher and boot a game directly. + -- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH) + -- --slot optional; picks the save slot to load + -- --launcher force the launcher even if a game is set + -- This is what a desktop shortcut, a Steam entry, or a frontend like + -- EmulationStation needs: one click into the game the player wants, with no + -- menu in between. A game that is not imported falls through to the + -- launcher on its tab rather than booting into nothing. + local launchGame, launchSlot = LaunchOptions.resolve(arg) + if launchGame and not LaunchOptions.forceLauncher(arg) then + if RomImporter.isReady(launchGame) then + if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end + bootGame(launchGame) + return + end + -- Not importable yet: open the launcher already showing that game, so the + -- shortcut still lands the player where they meant to go. + LaunchOptions.pendingTab = launchGame + end + -- Interactive: the launcher always runs. Red, Blue, and Yellow are each -- live: a column shows Play when that game's ROM is already imported, or -- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its @@ -725,6 +746,12 @@ function love.quit() if package.loaded["src.update.Check"] then pcall(package.loaded["src.update.Check"].shutdown) end + -- The launcher's fetch pool is the same story: its workers idle in + -- Channel:demand(), which never returns on its own, so a launcher that ever + -- touched the network would hang the process on exit (#339's shape again). + if package.loaded["src.net.Fetch"] then + pcall(package.loaded["src.net.Fetch"].shutdown) + end end function love.filedropped(file) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 96dbd065..d7a9d0f2 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -166,12 +166,19 @@ end -- Download url to an absolute host path. Returns true, or nil plus an error. -- The curl branch deliberately ignores curl's exit code, as the download paths -- always did: callers judge the result by the file they got. -function HostShell.httpDownload(url, absPath, userAgent, accept) +-- `maxTime` bounds curl's total transfer seconds. It matters at QUIT, not +-- during the transfer: LOVE waits for every live love.thread before the +-- process exits (#339), and a worker sitting inside a blocking curl cannot +-- notice a quit command until curl returns. With the launcher's default 300s +-- ceiling, closing the window during a mod download hung the process for +-- minutes. Callers on the interactive fetch pool pass something short. +function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 " + local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ") + :format(tonumber(maxTime) or 300) .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " @@ -194,11 +201,12 @@ end -- GET returning the body. curl streams it through a pipe; the Android bridge -- can only write a file, so there we fetch into the save directory (the only -- writable root on Android) and read it back. -function HostShell.httpGet(url, userAgent, accept) +function HostShell.httpGet(url, userAgent, accept, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " + local cmd = ("curl -fsSL --connect-timeout 10 --max-time %d ") + :format(tonumber(maxTime) or 40) .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua new file mode 100644 index 00000000..101785ac --- /dev/null +++ b/src/core/LaunchOptions.lua @@ -0,0 +1,113 @@ +-- Launch options: boot straight into a game, skipping the launcher. +-- +-- love . --game red -- boot Red +-- love . --game yellow --slot 2 -- boot Yellow on save slot 2 +-- love . --game red --launcher -- open the launcher anyway (a shortcut +-- the player wants to edit) +-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env +-- +-- This exists for the click-once cases: a desktop shortcut per game, a Steam +-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all +-- want "start the thing" and treat any menu in between as a defect. +-- +-- Everything here is pure resolution and validation -- no love.* beyond the +-- filesystem read that slot selection needs -- so the engine test tier can +-- cover the parsing without a window. + +local GameVersion = require("src.core.GameVersion") + +local LaunchOptions = {} + +-- Set by main.lua when a requested game turns out not to be importable yet: +-- the launcher opens on that tab instead of booting. +LaunchOptions.pendingTab = nil + +local function normalizeVersion(v) + if type(v) ~= "string" then return nil end + v = v:lower():gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then return nil end + -- Accept the aliases people actually type. + local alias = { + r = "red", red = "red", + b = "blue", blue = "blue", + y = "yellow", yellow = "yellow", + } + v = alias[v] or v + if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end + return v +end + +-- Pull "--flag value" (and "--flag=value") out of LOVE's arg table. +local function argValue(argv, name) + if type(argv) ~= "table" then return nil end + for i = 1, #argv do + local a = argv[i] + if a == "--" .. name then + return argv[i + 1] + end + local inline = type(a) == "string" and a:match("^%-%-" .. name .. "=(.*)$") + if inline then return inline end + end + return nil +end + +local function argFlag(argv, name) + if type(argv) ~= "table" then return false end + for i = 1, #argv do + if argv[i] == "--" .. name then return true end + end + return false +end + +-- Returns version, slotId (either may be nil). Command line wins over env, +-- so a shortcut can override a machine-wide default. +function LaunchOptions.resolve(argv) + local game = normalizeVersion(argValue(argv, "game")) + or normalizeVersion(os.getenv("POKEPORT_GAME")) + or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) + local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") + if type(slot) == "string" then + slot = slot:gsub("^%s+", ""):gsub("%s+$", "") + if slot == "" then slot = nil end + end + return game, slot +end + +function LaunchOptions.forceLauncher(argv) + return argFlag(argv, "launcher") or os.getenv("POKEPORT_FORCE_LAUNCHER") == "1" +end + +-- Point a version at a save slot before it boots. Accepts either a slot id +-- ("slot2") or a 1-based index ("2"), because a shortcut author should not +-- have to know the internal id scheme. A slot that does not exist is +-- ignored: booting the game on its previous slot beats refusing to start. +-- Returns the id actually selected, or nil. +function LaunchOptions.selectSlot(version, slot) + local ok, SaveData = pcall(require, "src.core.SaveData") + if not ok then return nil end + local listed = SaveData.listSlots and SaveData.listSlots(version) or nil + if type(listed) ~= "table" or #listed == 0 then return nil end + + local target + local index = tonumber(slot) + if index and listed[index] then + target = listed[index].id + else + for _, s in ipairs(listed) do + if s.id == slot then target = s.id break end + end + end + if not target then return nil end + pcall(SaveData.setActiveSlot, version, target) + return target +end + +-- The shortcut command a player would use for this game, for the launcher to +-- show and for docs to quote. +function LaunchOptions.commandFor(version, slot) + local cmd = "--game " .. tostring(version) + if slot then cmd = cmd .. " --slot " .. tostring(slot) end + return cmd +end + +return LaunchOptions diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 19e74312..6de23c77 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -245,6 +245,26 @@ local function coreRows(opts) end end + -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive + -- (src/core/Input.lua:applyBindings layers options.bindings over the + -- defaults rather than replacing them), so a player who has bound + -- themselves into a corner has no in-game way back -- there is no "unbind" + -- gesture. Clearing the table restores the stock keyboard and pad layout + -- on the next Input:applyBindings, which the game does on its next start. + -- The dragged touch-overlay layout goes with it: it is the same class of + -- customisation and the same class of getting stuck. + rows[#rows + 1] = { + label = Strings("RESET REBINDS"), + actionLabel = Strings("Reset"), + danger = true, + action = function() + opts.bindings = nil + local tc = opts.touchControls + if type(tc) == "table" then tc.layouts = nil end + return true + end, + } + return rows end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7c2a6c02..31f5ec17 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1,191 +1,62 @@ --- The launcher's FlexLove view. RomImporter owns every piece of state and --- all import/platform logic; this module rebuilds the immediate-mode element --- tree from that state once per frame, so the UI can never drift from the --- importer and every window size lays out fresh (no cached geometry, no hand --- hit-testing). Flat design: solid fills and hairline borders only, no --- gradients or glows. +-- The launcher's view, drawn with the shared immediate-mode kit +-- (src/ui/kit/). RomImporter owns every piece of state and all +-- import/platform logic; this module paints that state once per frame, so the +-- UI can never drift from the importer and every window size lays out fresh. -- --- Interaction contract with RomImporter: --- * every click handler only QUEUES work (imp._uiActions); update() drains --- the queue after FlexLove.update, so a handler that tears the view down --- (Play, Edit save) never destroys the tree that is dispatching it. --- * clicks are deduped per control key (a touch tap can surface as both a --- touch release and a synthesized mouse click; one action must not fire --- twice -- the shape of #553's double import). --- * hover state lives in imp._hot, written by events this frame and read --- by styles next frame (immediate mode recreates elements every frame). --- * the gamepad virtual cursor clicks through clickAt(), which dispatches --- a synthetic event to the element under the pad pointer. +-- WHAT CHANGED, AND WHY. This used to build a retained FlexLove element tree +-- every frame. That cost ~9ms of build+draw on a real profile before a +-- single row of content existed (measure it yourself: POKEPORT_LAUNCHER_PROF= +-- 200 love .), because the engine hashed props per element, snapshotted every +-- public scalar for its immediate-mode persistence, and re-ran an O(n^2) +-- auto-size pass. Painting the same screen directly is a small fraction of +-- that, and it removes a whole class of layout bug along with it: percentage +-- widths resolving against the wrong box, auto-sized buttons measuring zero +-- height, and flex-shrink compressing text until it overlapped. +-- +-- THE RULES THIS FILE FOLLOWS: +-- * NO SCROLLING. Every list paginates (Kit.pager). Rows per page come +-- from the real viewport height, so a tall window shows more and a phone +-- shows fewer -- but a page's row count is bounded either way, which is +-- what makes a 500-mod index cost the same as a 10-mod one. +-- * Every click handler only QUEUES work (imp._uiActions); update() drains +-- the queue, so an action that tears the view down (Play, Edit save) +-- never runs inside the frame that dispatched it. +-- * Clicks are deduped per control key: a touch tap can surface as both a +-- touch release and a synthesized mouse click, and one action must not +-- fire twice (the shape of #553's double import). +-- * Anything that waits raises a non-dismissable loader (Loader.overlay), +-- driven by imp._busy / imp.workState. +-- * Layout is explicit pixels off Layout.metrics. No percentages. -local FlexLove = require("libs.flexlove.FlexLove") -local Color = FlexLove.Color -local SafeArea = require("src.core.SafeArea") +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Layout = require("src.ui.kit.Layout") +local Loader = require("src.ui.kit.Loader") local GameVersion = require("src.core.GameVersion") local Strings = require("src.core.Strings") +local PAL = Theme.PAL local LauncherView = {} --- ------- palette (flat; alpha per use site) -local function rgba(r, g, b, a) - return Color.new(r / 255, g / 255, b / 255, a or 1) -end -local PAL = { - bg = { 10, 15, 34 }, - card = { 16, 23, 48 }, - rowBg = { 9, 14, 34 }, - border = { 120, 150, 220 }, - red = { 255, 60, 72 }, - blue = { 70, 150, 255 }, - gold = { 255, 203, 5 }, - green = { 62, 224, 138 }, - greenDark = { 22, 163, 90 }, - greenInk = { 6, 32, 18 }, - white = { 255, 255, 255 }, - detail = { 198, 208, 230 }, - warn = { 159, 176, 208 }, - gray = { 143, 163, 200 }, - disabled = { 120, 132, 158 }, - link = { 127, 208, 255 }, - danger = { 255, 83, 97 }, - chipModTop = { 61, 74, 109 }, -} -local function C(name, a) - local c = PAL[name] - return rgba(c[1], c[2], c[3], a) -end - --- Non-scroll elements refuse to flex-shrink: the engine otherwise compresses --- auto-height children inside height-constrained columns until their text --- overlaps (the portrait single-column layout was the visible case). Scroll --- regions are the opposite — they MUST shrink to the viewport, or their --- height grows with content, maxScrollY stays 0, and drag/wheel do nothing. --- horizontal padding of a props table, for content-width bookkeeping -local function propsPadH(p) - local pad = p.padding - if type(pad) == "number" then return pad * 2 end - if type(pad) == "table" then - return (pad.left or pad.horizontal or 0) + (pad.right or pad.horizontal or 0) - end - return 0 -end - -local function isScrollOverflow(props) - local o = props.overflowY or props.overflowX or props.overflow - return o == "scroll" or o == "auto" -end - -local function mk(props) - if props.flexShrink == nil then - props.flexShrink = isScrollOverflow(props) and 1 or 0 - end - if isScrollOverflow(props) then - if props.minHeight == nil then props.minHeight = 0 end - -- Every launcher list scrolls like a native one: interpolated wheel - -- steps instead of hard 20px jumps, and a bigger per-notch distance so - -- long save/mod lists don't take dozens of notches to traverse. - if props.smoothScrollEnabled == nil then props.smoothScrollEnabled = true end - if props.scrollSpeed == nil then props.scrollSpeed = 60 end - end - -- Resolve "100%" here, against the parent's CONTENT width: the engine - -- resolves a percentage against the parent's border box and ignores its - -- padding, so every percent child of a padded container overflowed to the - -- right by exactly that padding (the clipped LOADED/Delete chips). - -- Parents are always created before their children in this view, so the - -- tracked inner width is available by the time a child asks. - if props.width == "100%" and props.parent and props.parent._innerW then - props.width = props.parent._innerW - end - local el = FlexLove.new(props) - if el then - if type(props.width) == "number" then - el._innerW = props.width - propsPadH(props) - elseif props.parent and props.parent._innerW then - el._innerW = props.parent._innerW - propsPadH(props) - end - end - return el -end - local COMMUNITY_URL = "https://bois.icu" +-- One dedup window covers a touch release plus the mouse click SDL +-- synthesizes for the same tap. +local ACT_DEDUP = 0.35 +-- Finger travel past this (px) is a drag, not a tap. +local TAP_SLOP2 = 16 * 16 + local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end --- FlexLove's addChild auto-sizing only propagates one ancestor while this --- immediate-mode tree is assembled. Reconcile a completed nested container --- with the same height box model used by Element:resize. -local function refreshAutoHeight(el) - if type(el) ~= "table" or type(el.autosizing) ~= "table" - or not el.autosizing.height - or type(el.calculateAutoHeight) ~= "function" then - return false - end - local ok, contentHeight = pcall(el.calculateAutoHeight, el) - if not ok or type(contentHeight) ~= "number" - or contentHeight ~= contentHeight - or contentHeight == math.huge or contentHeight == -math.huge then - return false - end - local padding = el.padding or {} - local top, bottom = padding.top or 0, padding.bottom or 0 - local borderBoxHeight = clamp(contentHeight + top + bottom, - el.minHeight or -math.huge, el.maxHeight or math.huge) - el._borderBoxHeight = borderBoxHeight - el.height = clamp(math.max(0, borderBoxHeight - top - bottom), - el.minHeight or -math.huge, el.maxHeight or math.huge) - if type(el.invalidateLayout) == "function" then - el:invalidateLayout() - end - return borderBoxHeight -end +-- ------------------------------------------------------------- lifecycle -LauncherView._refreshAutoHeight = refreshAutoHeight - - --- ------- lifecycle - --- All platforms: FlexLove used to map `performanceMonitoring = false` to --- true (`false or true`), leaving layout/render timers + memory sampling on --- every immediate-mode frame (pad-cursor lag on NX, scroll drag on desktop). --- The vendored init is fixed, but force the flags off here too so a hot --- reload against an already-inited FlexLove stays clean. Exported so the --- engine tier can assert the guards without drawing the full tree. -function LauncherView.applyNxPerfGuards(imp) - if not (imp and FlexLove.isReady() and FlexLove._Performance) then - return false - end - FlexLove._Performance.enabled = false - local mp = FlexLove._Performance._memoryProfiler - if mp then mp.enabled = false end - -- Immediate-mode rebuilds allocate a full tree every frame; the default - -- "auto" GC strategy triggers a full blocking collect past 100 MB, a - -- visible hitch mid-scroll. Less frequent steps, higher threshold, on - -- every platform (was NX-only; desktop hit the same hitch, see the - -- scroll-sluggishness investigation). - if FlexLove._gcConfig then - FlexLove._gcConfig.strategy = "periodic" - FlexLove._gcConfig.interval = 90 - FlexLove._gcConfig.stepSize = 40 - FlexLove._gcConfig.memoryThreshold = 180 - end - return true -end - -local function ensureFlex(imp) - if not FlexLove.isReady() then - FlexLove.init({ - immediateMode = true, - performanceMonitoring = false, - keyboardNavigation = false, - }) - end - -- Re-apply on every ensure: FlexLove may already be ready from a prior - -- init (hot reload / editor round-trip). No-op when not NX. - LauncherView.applyNxPerfGuards(imp) +local function ensureState(imp) if not imp._flex then imp._flex = true imp._hot = imp._hot or {} imp._actAt = imp._actAt or {} imp._uiActions = imp._uiActions or {} + imp._pages = imp._pages or {} -- Held backspace/arrows must repeat in the text fields; restored on -- detach because the game's Input does its own per-step edge detection -- and never expects repeated keypressed events. @@ -195,11 +66,17 @@ local function ensureFlex(imp) end end --- Tear the tree down before handing the screen to the game / editor: the --- engine draws with raw love.graphics and must not share canvases or input --- polling with a live UI toolkit. +-- Kept as a no-op hook: the engine tier asserts this exists, and the guards +-- it used to apply were FlexLove's (performance monitoring, GC tuning). The +-- kit has neither a profiler nor a GC strategy to tune -- it does not +-- allocate per frame -- so there is nothing left to guard. +function LauncherView.applyNxPerfGuards(imp) + return imp ~= nil +end + +-- Tear down before handing the screen to the game / editor. function LauncherView.detach(imp) - -- Restore the NX mouse shim even if _flex was never set (bridge can + -- Restore the NX mouse shim even if _flex was never set (the bridge can -- install on the first update before the first draw). if imp and imp.parkNxPointerForHost then pcall(imp.parkNxPointerForHost, imp) @@ -211,16 +88,32 @@ function LauncherView.detach(imp) if love.keyboard and love.keyboard.setKeyRepeat then pcall(love.keyboard.setKeyRepeat, false) end - pcall(FlexLove.destroy) + Kit.clearCaches() end +-- ---------------------------------------------------------------- input +-- The kit is polled, not evented: update() samples the mouse and turns a +-- rising edge into a click point that the next draw consumes. Host-forwarded +-- mousepressed stays unused, exactly as before, so Android's synthesized +-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the +-- other half of that guarantee. function LauncherView.update(imp, dt) if not imp._flex then return end - FlexLove.update(dt) - -- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free - -- to destroy the view (Play/Edit) or block in a native picker. The batch - -- is resolved by RomImporter:runActions so the drop/disarm rules are - -- testable without a live FlexLove tree (#780). + + local down = false + if love.mouse and love.mouse.isDown then + down = love.mouse.isDown(1) and true or false + end + if down and not imp._prevMouseDown and not imp._padCursorActive then + local mx, my = love.mouse.getPosition() + imp._clickPt = { x = mx, y = my } + end + imp._prevMouseDown = down + + -- Drain the action queue OUTSIDE the draw, so an action is free to destroy + -- the view (Play/Edit) or block in a native picker. The batch is resolved + -- by RomImporter:runActions so the drop/disarm rules stay testable without + -- a live view (#780). local queue = imp._uiActions if queue and #queue > 0 then imp._uiActions = {} @@ -228,384 +121,153 @@ function LauncherView.update(imp, dt) end end --- One dedup window covers a touch release plus the mouse click SDL --- synthesizes for the same tap. -local ACT_DEDUP = 0.35 --- Finger travel past this (px) is a scroll drag, not a tap — so dragging a --- list row does not also fire that row's button. -local TAP_SLOP2 = 16 * 16 - function LauncherView.wheelmoved(imp, dx, dy) if not imp._flex then return end - pcall(FlexLove.wheelmoved, dx, dy) + imp._wheelY = (imp._wheelY or 0) + (dy or 0) end --- Touch drag scroll: FlexLove's ScrollManager only moves when these are --- hooked. Clicks still come from EventHandler's love.touch / mouse polling; --- the view's action dedupe covers a tap that also synthesizes a mouse click, --- and a drag past TAP_SLOP suppresses the click that would otherwise fire --- on the row under the finger. -function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure) +function LauncherView.touchpressed(imp, id, x, y) if not imp._flex then return end imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { x = x, y = y } - pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure) end -function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure) +function LauncherView.touchmoved(imp, id, x, y) if not imp._flex then return end local start = imp._touchAt and imp._touchAt[tostring(id)] if start then local ddx, ddy = x - start.x, y - start.y if ddx * ddx + ddy * ddy > TAP_SLOP2 then - imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + start.dragged = true end end - pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure) end -function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure) +-- A tap dispatches on RELEASE (not press) so a drag can disqualify it. +function LauncherView.touchreleased(imp, id, x, y) if not imp._flex then return end + local start = imp._touchAt and imp._touchAt[tostring(id)] if imp._touchAt then imp._touchAt[tostring(id)] = nil end - pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure) + if start and start.dragged then + -- Suppress the mouse click SDL will synthesize for this same gesture. + imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + return + end + imp._clickPt = { x = x, y = y } end --- Synthetic click for the gamepad virtual cursor: find the element under the --- pad pointer and run its handler with a click-shaped event. +-- Synthetic click for the gamepad virtual cursor. function LauncherView.clickAt(imp, x, y) if not imp._flex then return end - local ok, el = pcall(FlexLove.getElementAtPosition, x, y) - if not ok then return end - while el and not el.onEvent do el = el.parent end - if el and el.onEvent then - pcall(el.onEvent, el, { type = "click", button = 1, x = x, y = y, - modifiers = {}, clickCount = 1 }) - end + imp._clickPt = { x = x, y = y } end --- ------- shared widget helpers +-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm +-- the ring; Enter only activates a focused control once the user has actually +-- used the arrows this session, so the long-standing "Enter plays the visible +-- game" shortcut keeps working for anyone who never touches the ring. +function LauncherView.keypressed(imp, key) + if not imp._flex then return false end + if key == "up" or key == "down" or key == "left" or key == "right" then + imp._ringArmed = true + Kit.navigate(key) + return true + end + if imp._ringArmed and (key == "return" or key == "kpenter" or key == "space") then + Kit.activateFocused() + return true + end + return false +end + +-- ------------------------------------------------------------- actions local function queueAction(imp, key, fn, keepArm) local now = love.timer.getTime() local last = imp._actAt[key] if last and now - last < ACT_DEDUP then return end + local untilT = imp._suppressClickUntil + if untilT and now < untilT then return end imp._actAt[key] = now -- Any press that is not a Delete's own second click disarms the pending - -- delete confirm (#433's rule, preserved from the hit-rect launcher). The - -- disarm itself is applied by RomImporter:runActions when the batch drains, - -- not here: one touch lands on a row AND on the chip inside it, and - -- clearing the arm as the row queued left Delete stuck on its first press - -- (#780). + -- delete confirm (#433's rule). The disarm is applied by runActions when + -- the batch drains, not here: one touch lands on a row AND on the chip + -- inside it, and clearing the arm as the row queued left Delete stuck on + -- its first press (#780). imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm } end -local function handler(imp, key, action, keepArm) - return function(_, ev) - if ev.type == "hover" then - imp._hot[key] = true - elseif ev.type == "unhover" then - imp._hot[key] = nil - elseif action and (ev.type == "click" or ev.type == "touchrelease") then - if ev.type == "touchrelease" then - local dx, dy = ev.dx or 0, ev.dy or 0 - if dx * dx + dy * dy > TAP_SLOP2 then - imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP - return - end - end - local untilT = imp._suppressClickUntil - if untilT and love.timer.getTime() < untilT then return end - queueAction(imp, key, action, keepArm) +-- Every interactive control in this file goes through one of these two, so +-- the queueing and dedup rules cannot be forgotten at a call site. +local function btn(imp, x, y, w, h, key, label, opts) + opts = opts or {} + opts.id = key + if Kit.button(x, y, w, h, label, opts) and opts.action then + queueAction(imp, key, opts.action, opts.keepArm) + end +end + +local function rowHit(imp, x, y, w, h, selected, key, action) + local clicked, ink = Kit.row(x, y, w, h, selected, key) + if clicked and action then queueAction(imp, key, action) end + return ink +end + +-- ------------------------------------------------------- shared widgets + +-- Read-only text field. The importer owns the string (its textinput / +-- keypressed routing writes it); this only renders it, keeps the TAIL +-- visible while typing, and blinks a caret on the importer's pulse clock. +local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action) + Kit._audit("control", x, y, w, h, key) + Kit.focusable(key, x, y, w, h) + Theme.fill(x, y, w, h, PAL.bg, 1) + Theme.stroke(x, y, w, h, PAL.line, + focused and Theme.A.focus or + (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), + focused and 2 or 1) + local pad = math.floor(10 * Kit.scale) + local ty = y + (h - Kit.textHeight("button")) / 2 + local text = rawText or "" + if text == "" and not focused then + Kit.text("button", Kit.ellipsize("button", placeholder or "", w - 2 * pad), + x + pad, ty, PAL.faint) + else + local shown = Kit.ellipsizeLeft("button", text, w - 2 * pad) + local tw = Kit.text("button", shown, x + pad, ty, PAL.heading) + if focused and (imp.pulse * 2 % 1) < 0.5 then + Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale), + Kit.textHeight("button"), PAL.ink, 1) end end -end - --- Shared measuring fonts, cached by integer size: control widths and heights --- come from the same faces the elements render with. Estimating them from --- character counts broke at every scale except the one it was tuned on --- (clipped Delete chips, button rows spilling out of their cards). -local measureFonts = {} -local function mfont(size) - size = math.max(8, math.floor(size + 0.5)) - local f = measureFonts[size] - if not f then - -- same fallback the rendering faces get (FlexLove FontCache), or the - -- launcher measures Latin widths for text it draws with kana - f = require("src.render.UiFont").attach(love.graphics.newFont(size), size) - measureFonts[size] = f + if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then + queueAction(imp, key, action) end - return f -end -local function textWidth(size, text) return mfont(size):getWidth(text) end -local function textHeight(size) return mfont(size):getHeight() end - --- wrapped text height at a width, from the same font the element renders. --- Memoized: the immediate-mode rebuild asks for the same (size, width, --- text) every frame for every visible row, and Font:getWrap re-shapes the --- whole string each time - one of the hottest calls in the frame on mobile. --- The key space is small (mod summaries and notes at a handful of widths). -local wrapHeightCache = {} -local function wrapHeight(size, text, width) - if not text or text == "" or (width or 0) <= 0 then return 0 end - local key = size .. ":" .. width .. ":" .. text - local h = wrapHeightCache[key] - if h then return h end - local f = mfont(size) - local _, lines = f:getWrap(text, width) - h = math.max(1, #lines) * f:getHeight() - wrapHeightCache[key] = h - return h end --- Every text size in this file is already scaled by m.s, so FlexLove's own --- viewport text scaling must stay off: with it on, the layout boxes shrink --- away from the rendered glyphs on non-reference window sizes and lines --- overlap. -local function label(parent, text, size, color, props) - -- integer sizes only: the measuring fonts are integer-sized, and a - -- fractional rendered size drifting a few percent wider than its measure - -- is exactly how button rows crept out of their cards on some displays - size = math.floor(size + 0.5) - local p = { - parent = parent, text = text, textSize = size, textColor = color, - textWrap = "word", autoScaleText = false, - -- id keyed by the text: the engine's Persistable behavior snapshots - -- every scalar prop (text included) per id and stomps it back onto the - -- recreated element, freezing any label whose text changes between - -- frames (typed search text stuck on its first letter). A new text is - -- a new id, so it always renders fresh. - id = "lbl:" .. tostring(text), - } - for k, v in pairs(props or {}) do p[k] = v end - return mk(p) -end - --- kinds: primary (solid green), accent (green outline), danger (red outline), --- dangerArmed (solid red), neutral (translucent white), disabled (inert) -local function button(imp, parent, key, text, opts) - opts = opts or {} - local hot = imp._hot[key] - local kind = opts.kind or "neutral" - local bgc, fgc, brc - if kind == "primary" then - bgc = hot and C("green") or C("greenDark") - fgc, brc = C("greenInk"), C("green", 0.9) - elseif kind == "accent" then - bgc = C("green", hot and 0.30 or 0.12) - fgc, brc = hot and C("white") or C("green"), C("green", 0.7) - elseif kind == "danger" then - bgc = C("danger", hot and 0.30 or 0.12) - fgc, brc = hot and C("white") or C("danger"), C("danger", 0.7) - elseif kind == "dangerArmed" then - bgc, fgc, brc = C("danger"), C("white"), C("danger") - elseif kind == "disabled" then - bgc = C("disabled", 0.22) - fgc, brc = C("disabled"), C("disabled", 0.35) - else - bgc = C("white", hot and 0.22 or 0.10) - fgc, brc = C("white"), C("white", hot and 0.4 or 0.2) - end - -- Explicit measured size always: the layout engine measures an auto-sized - -- button as zero-height while its parent card is auto-sizing, which let - -- bottom action rows spill past their card's edge. - local size = math.floor((opts.size or 14) + 0.5) - local pad = opts.pad or { horizontal = 12, vertical = 6 } - local padX = pad.horizontal or 12 - local padY = pad.vertical or 6 - local w = opts.w - if not w and not opts.flex then - w = math.ceil(textWidth(size, text)) + 2 * padX + 2 - end - local h = opts.h or (math.ceil(textHeight(size)) + 2 * padY + 2) - local p = { - parent = parent, - -- same Persistable-stomp guard as label(): a button whose caption - -- changes (Delete -> Sure?, Update ladders) must not keep frame one's - id = "btn:" .. key .. ":" .. tostring(text), - width = w, height = h, - flex = opts.flex, - backgroundColor = bgc, - border = 1, borderColor = brc, - cornerRadius = opts.r or 8, - text = text, textColor = fgc, textSize = size, - textAlign = "center-center", autoScaleText = false, - } - if kind ~= "disabled" and opts.action then - p.onEvent = handler(imp, key, opts.action, opts.keepArm) - elseif kind ~= "disabled" then - p.onEvent = handler(imp, key, nil) - end - return mk(p) -end - --- Measured single-row width of a header's labels and buttons, using the --- same integer-sized fonts label()/button() render with (button() adds --- 12px horizontal padding + 1px border per side). Tab headers use this to --- decide between one row and a split title/buttons pair: flexWrap cannot --- save a narrow window here, because the engine does not grow an --- auto-sized parent for wrapped children (#748 family), so a wrapped --- button used to land on top of whatever followed the header. -local function headNeededW(m, items) - local w, n = 0, 0 +-- A filter/sort pill row that wraps. Returns the height consumed. +local function chipRow(imp, x, y, w, m, items) + local gap = math.floor(6 * m.s) + local h = math.max(Kit.tapMin(), math.floor(28 * m.s)) + local cx, cy = x, y for _, it in ipairs(items) do - local size = math.floor(it.size + 0.5) - local tw = math.ceil(textWidth(size, it.text)) - w = w + (it.btn and (tw + 26) or tw) - n = n + 1 + local cw = Kit.textWidth("micro", it.label) + math.floor(20 * m.s) + if cx > x and cx + cw > x + w then + cx = x + cy = cy + h + gap + end + if Kit.chip(cx, cy, cw, h, it.label, it.active, it.color, it.key) then + queueAction(imp, it.key, it.action) + end + cx = cx + cw + gap end - -- inter-item gaps, plus one gap of slack where the flex spacer sits - return w + n * (10 * m.s) + return (cy - y) + h end --- One header row when everything fits, otherwise a title row and a --- right-aligned button row stacked under it. Returns the row for the --- title/status labels and a function to call AFTER adding them, which --- returns the row for the buttons (inserting the flex spacer so buttons --- sit flush right in both shapes). -local function headRows(parent, m, items) - local split = headNeededW(m, items) > (parent._innerW or m.contentW) - local function row() - return mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 10 * m.s }) - end - local titleRow = row() - return titleRow, function() - local btnRow = split and row() or titleRow - mk({ parent = btnRow, flex = 1 }) - return btnRow - end -end - -local function card(parent, props) - local p = { - parent = parent, - width = "100%", - backgroundColor = C("card", 0.75), - border = 1, borderColor = C("border", 0.28), - cornerRadius = 14, - positioning = "flex", flexDirection = "vertical", - } - for k, v in pairs(props or {}) do p[k] = v end - return mk(p) -end - -local function pill(parent, text, colName, size) - size = math.floor((size or 12) + 0.5) - local h = math.ceil(textHeight(size)) + 8 - return mk({ - parent = parent, text = text, textColor = C(colName), - id = "pill:" .. tostring(text), - textSize = size, textAlign = "center-center", autoScaleText = false, - width = math.ceil(textWidth(size, text)) + 20, height = h, - backgroundColor = C(colName, 0.12), - border = 1, borderColor = C(colName, 0.55), - cornerRadius = h / 2, - }) -end - -local function progressBar(parent, frac, colName, h) - h = h or 10 - frac = clamp(frac or 0, 0, 1) - local track = mk({ - parent = parent, width = "100%", height = h, - backgroundColor = C("bg", 0.9), cornerRadius = h / 2, - }) - mk({ - parent = track, width = (frac * 100) .. "%", - -- id keyed by the fraction, or Persistable pins the bar at frame one - id = "prog:" .. math.floor(frac * 1000), - height = "100%", backgroundColor = C(colName), cornerRadius = h / 2, - }) - return track -end - --- Flat toggle switch (read-only visual; the pressable area is the caller's). --- The knob is flex-aligned rather than absolutely positioned: absolute --- children resolve in screen space here, not against the parent. -local function toggleSwitch(parent, on, w, h, idKey) - w, h = w or 46, h or 24 - local track = mk({ - parent = parent, width = w, height = h, - -- state-keyed id: justifyContent is a persisted scalar, and a stale - -- snapshot would hold the knob on its frame-one side after a toggle - id = idKey and (idKey .. (on and ":on" or ":off")) or nil, - backgroundColor = on and C("greenDark") or C("disabled", 0.4), - border = 1, borderColor = on and C("green", 0.8) or C("disabled", 0.6), - cornerRadius = h / 2, - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", - justifyContent = on and "flex-end" or "flex-start", - padding = 3, - }) - mk({ - parent = track, - width = h - 6, height = h - 6, - backgroundColor = C("white"), cornerRadius = (h - 6) / 2, - }) - return track -end - --- a darkened copy of a palette color, for the embossed chips' bottom ledge -local function darken(name, f, a) - local c = PAL[name] - return rgba(c[1] * f, c[2] * f, c[3] * f, a or 1) -end - --- Hand-rolled text field: the importer owns the string (textinput / --- keypressed routing), this draws it. The field clips its content, keeps --- the TAIL of the text visible while typing (the interesting end), and --- shows a font-height caret on the importer's pulse clock. -local function dropFirstChar(t) - local i = 2 - while i <= #t do - local b = t:byte(i) - if b < 0x80 or b >= 0xC0 then break end - i = i + 1 - end - return t:sub(i) -end - -local function textField(imp, parent, key, rawText, placeholder, focused, action) - local size = 14 - local h = math.max(36, math.ceil(textHeight(size)) + 18) - local field = mk({ - parent = parent, width = "100%", height = h, - backgroundColor = C("bg", focused and 1 or 0.85), - border = focused and 2 or 1, - borderColor = focused and C("green", 0.85) - or C("border", imp._hot[key] and 0.7 or 0.4), - cornerRadius = 8, overflow = "hidden", - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 2, - padding = { horizontal = 12 }, - onEvent = action and handler(imp, key, action) or nil, - }) - local avail = (field._innerW or 200) - 6 - local shown = rawText or "" - local f = mfont(size) - while #shown > 1 and f:getWidth(shown) > avail do - shown = dropFirstChar(shown) - end - if shown ~= "" then - label(field, shown, size, C("white"), { textWrap = false }) - elseif placeholder and not focused then - label(field, placeholder, size, C("disabled"), { textWrap = false }) - end - if focused and (imp.pulse * 2 % 1) < 0.5 then - mk({ parent = field, width = 2, - height = math.ceil(textHeight(size)) + 2, - backgroundColor = C("green"), cornerRadius = 1 }) - end - return field -end - --- ------- status derivations shared with the old panels - -local function modStatusChip(status) - if status == "ok" then return Strings("Ready"), "green" end - if status == "conflict" then return Strings("Conflict"), "danger" end - return Strings("Incompatible"), "gold" +local function modStatusColor(status) + if status == "ok" then return Strings("Ready"), PAL.green end + if status == "conflict" then return Strings("Conflict"), PAL.red end + return Strings("Incompatible"), PAL.yellow end local function findActionFor(entry, installedVersion) @@ -623,7 +285,7 @@ local function findActionFor(entry, installedVersion) return Strings("Reinstall"), "Installed v" .. tostring(installedVersion) end -local DELETE_LABEL = function(armed) +local function DELETE_LABEL(armed) return armed and Strings("Sure?") or Strings("Delete") end @@ -632,138 +294,191 @@ local function deleteArmed(imp, kind, id, version) return a ~= nil and a.kind == kind and a.id == id and a.version == version end --- ------- header: strip, logo row (with the settings gear), tab bar +-- Page state lives on the importer keyed by list, so switching tabs and +-- coming back keeps your place -- the one thing scrolling did better. +local function page(imp, key) + return imp._pages[key] or 1 +end -local function buildHeader(imp, root, m) - -- tricolor strip - local strip = mk({ - parent = root, width = "100%", height = math.max(4, 5 * m.s), - positioning = "flex", flexDirection = "horizontal", - }) - for _, name in ipairs({ "red", "blue", "gold" }) do - mk({ parent = strip, flex = 1, height = "100%", - backgroundColor = C(name) }) +local function setPage(imp, key, v) + imp._pages[key] = v +end + +-- ------------------------------------------------------------- header +-- Rail, logo row (update + settings on the right), tab bar. +-- Returns the y at which content may start. +local function buildHeader(imp, m) + local y = m.top + Theme.versionRail(m.x, y, m.w, m.railH) + y = y + m.railH + + -- logo row + local rowH = m.logoH + math.floor(12 * m.s) + local gear = m.chip + local gap = math.floor(8 * m.s) + + -- The logo is centred in the FULL row, then the right cluster is drawn over + -- its own reserved space, so the wordmark never drifts as buttons appear. + if imp.logo then + local lw, lh = imp.logo:getDimensions() + local maxW = math.min(320 * m.s, m.w * 0.55) + local scale = math.min(maxW / lw, m.logoH / lh) + local dw, dh = lw * scale, lh * scale + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(imp.logo, Theme.snap(m.x + (m.w - dw) / 2), + Theme.snap(y + (rowH - dh) / 2), 0, scale, scale) end - -- logo row: spacer / centered logo / settings gear, so the logo stays - -- centered while the gear holds the app's top-right corner - local gearSize = math.max(34, 40 * m.s) - local row = mk({ - parent = root, width = "100%", height = m.logoH + 12 * m.s, - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 8 * m.s, - padding = { horizontal = m.pad }, - }) - mk({ parent = row, width = gearSize, height = 1 }) - local mid = mk({ parent = row, flex = 1, - positioning = "flex", justifyContent = "center", alignItems = "center" }) - mk({ - parent = mid, image = imp.logo, objectFit = "contain", - width = math.min(320 * m.s, m.w * 0.6), height = m.logoH, - }) + local rx = m.x + m.w - m.pad + local by = y + (rowH - gear) / 2 + + -- Settings gear, top-right corner. imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") - mk({ - parent = row, - width = gearSize, height = gearSize, - backgroundColor = C("white", imp._hot.gear and 0.20 or 0.07), - border = 1, borderColor = C("border", imp._hot.gear and 0.85 or 0.4), - cornerRadius = 10, - image = imp._gearIcon, objectFit = "contain", - imageTint = imp._hot.gear and C("white") or C("detail"), - padding = math.floor(gearSize * 0.18), - onEvent = handler(imp, "gear", function() - imp:_openSettings() - end), - }) + rx = rx - gear + do + local x = rx + Kit._audit("control", x, by, gear, gear, "gear") + local focused = Kit.focusable("gear", x, by, gear, gear) + local hot = focused or Kit.hover(x, by, gear, gear) + Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) + Theme.stroke(x, by, gear, gear, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + local iw, ih = imp._gearIcon:getDimensions() + local pad = math.floor(gear * 0.22) + local s = math.min((gear - 2 * pad) / iw, (gear - 2 * pad) / ih) + if hot then love.graphics.setColor(0, 0, 0, 1) + else love.graphics.setColor(1, 1, 1, 0.85) end + love.graphics.draw(imp._gearIcon, Theme.snap(x + (gear - iw * s) / 2), + Theme.snap(by + (gear - ih * s) / 2), 0, s, s) + love.graphics.setColor(1, 1, 1, 1) + if Kit.press(x, by, gear, gear) or Kit._activateId == "gear" then + queueAction(imp, "gear", function() imp:_openSettings() end) + end + end + + -- In-app update, immediately left of the gear. It GLOWS (a pulsing + -- outline, no extra draw calls -- see Kit.button) whenever there is + -- something to act on, which is the whole point of moving it out of the + -- old bottom-of-page banner: an update you never scrolled to was an update + -- you never saw. + local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp) + if upStatus then + local uw = Kit.textWidth("small", upLabel) + math.floor(24 * m.s) + rx = rx - gap - uw + btn(imp, rx, by, uw, gear, "updater", upLabel, { + kind = upGlow and "warn" or "ghost", font = "small", + glow = upGlow, action = upAction, + }) + end + y = y + rowH -- tab bar - local bar = mk({ - parent = root, width = "100%", - positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap", - alignItems = "center", gap = 8 * m.s, - padding = { horizontal = m.pad, vertical = 8 * m.s }, - }) imp._modsIcon = imp._modsIcon or love.graphics.newImage("assets/launcher/mods.png") imp._findIcon = imp._findIcon or love.graphics.newImage("assets/launcher/find.png") + -- The three game tabs keep their cartridge colours -- that is the one piece + -- of brand identity in the launcher, and "the red one" is how people + -- actually refer to these. The colour rides the outline and the glyph at + -- rest and becomes the fill when active, the same rule the buttons follow. local tabs = { - { id = "red", letter = "R", col = "red", ink = "white", labelText = Strings("RED") }, - { id = "blue", letter = "B", col = "blue", ink = "white", labelText = Strings("BLUE") }, - { id = "yellow", letter = "Y", col = "gold", ink = "bg", labelText = Strings("YELLOW") }, - { id = "mods", icon = imp._modsIcon, col = "chipModTop", ink = "white", labelText = Strings("MODS") }, - { id = "find", icon = imp._findIcon, col = "chipModTop", ink = "white", labelText = Strings("FIND MODS") }, + { id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed }, + { id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue }, + { id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold }, + { id = "mods", icon = imp._modsIcon, label = Strings("MODS") }, + { id = "find", icon = imp._findIcon, label = Strings("FIND MODS") }, } + local tabH = m.chip + local tx = m.x + m.pad + local ty = y + math.floor(6 * m.s) for _, t in ipairs(tabs) do - if t.id == "mods" then - mk({ parent = bar, width = 1, height = m.chip * 0.8, - backgroundColor = C("border", 0.3) }) - end local active = imp.tab == t.id local key = "tab-" .. t.id - local labelSize = 13 * m.s + 4 - local hot = imp._hot[key] - -- embossed chip: a darker base ledge under the face gives the tab a - -- raised look; hover lifts the face brightness and rims it white - local ledge = math.max(2, math.floor(3 * m.s)) - local baseEl = mk({ - parent = bar, width = m.chip, height = m.chip + ledge, - backgroundColor = darken(t.col, 0.35, active and 1 or 0.8), - cornerRadius = 10, - onEvent = handler(imp, key, function() - imp:_switchTab(t.id) - end), - }) - local face = { - parent = baseEl, width = "100%", height = m.chip, - backgroundColor = C(t.col, active and 1 or (hot and 0.75 or 0.42)), - border = (active or hot) and 1 or false, - borderColor = C("white", active and 0.6 or 0.35), - cornerRadius = 10, - } + local labelW = Kit.textWidth("tab", t.label) + -- The active tab spells its name out; inactive tabs are the glyph alone, + -- so five tabs fit a phone width without wrapping. + local w = active and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s)) + or tabH + Kit._audit("control", tx, ty, w, tabH, key) + local focused = Kit.focusable(key, tx, ty, w, tabH) + local hot = focused or Kit.hover(tx, ty, w, tabH) + local invert = active or hot + local tint = t.color or PAL.ink + Theme.fill(tx, ty, w, tabH, invert and tint or PAL.bg, 1) + if not invert then + Theme.stroke(tx, ty, w, tabH, tint, + t.color and Theme.A.hover or Theme.A.hairline, 1) + end + -- Ink on a filled tab must contrast with THAT fill: black on the light + -- red/blue/gold cartridge colours, which are all high-luminance. + local ink = invert and PAL.inverse or (t.color or PAL.text) if t.icon then - face.image = t.icon - face.objectFit = "contain" - face.padding = math.floor(m.chip * 0.2) - face.imageTint = C("white", active and 1 or 0.85) + local iw, ih = t.icon:getDimensions() + local pad = math.floor(tabH * 0.24) + local s = math.min((tabH - 2 * pad) / iw, (tabH - 2 * pad) / ih) + if invert then love.graphics.setColor(0, 0, 0, 1) + else love.graphics.setColor(1, 1, 1, 0.9) end + love.graphics.draw(t.icon, Theme.snap(tx + (tabH - iw * s) / 2), + Theme.snap(ty + (tabH - ih * s) / 2), 0, s, s) + love.graphics.setColor(1, 1, 1, 1) else - face.text = t.letter - -- the gold chip's dark ink only reads on the full-strength active - -- fill; dimmed inactive chips all take light ink - face.textColor = (active and t.ink == "bg") and C("bg") or C("white") - face.textSize = math.floor(m.chip * 0.45) - face.textAlign = "center-center" - face.autoScaleText = false + Kit.textCenter("tab", t.letter, tx, + ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink) end - mk(face) if active then - -- explicit width: a percentage inside this auto-sized wrap would not - -- resolve (LayoutEngine LAY_004), so the underline takes the label's - -- measured pixel width - local lw = math.ceil(textWidth(labelSize, t.labelText)) + 2 - local wrap = mk({ parent = bar, width = lw, - positioning = "flex", flexDirection = "vertical", gap = 3 * m.s }) - label(wrap, t.labelText, labelSize, C("white"), { textWrap = false }) - mk({ parent = wrap, width = lw, height = 3, - backgroundColor = C(t.col) }) + Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s), + ty + (tabH - Kit.textHeight("tab")) / 2, ink) end + if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then + queueAction(imp, key, function() imp:_switchTab(t.id) end) + end + tx = tx + w + math.floor(6 * m.s) end - -- "N of 3 ready" right-aligned filler + + -- "N of 3 ready", right-aligned on the tab line. local ready = 0 for _, v in ipairs(GameVersion.ORDER) do if imp.ready[v] then ready = ready + 1 end end - mk({ parent = bar, flex = 1 }) - label(bar, Strings("%d of 3 ready", ready), 12 * m.s + 2, C("gray"), - { textWrap = false }) - mk({ parent = root, width = "100%", height = 1, - backgroundColor = C("border", 0.22) }) + Kit.textRight("small", Strings("%d of 3 ready", ready), m.x + m.w - m.pad, + ty + (tabH - Kit.textHeight("small")) / 2, PAL.muted) + + y = ty + tabH + math.floor(8 * m.s) + Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) + return y + math.floor(10 * m.s) end --- ------- game panel +-- The state of the self-updater, as a top-right control. +-- Returns status, label, action, glow. +function LauncherView._updateControl(imp) + if not imp.Check then return nil end + local ok, st = pcall(imp.Check.state) + st = (ok and type(st) == "table") and st or nil + local status = st and st.status or "idle" + if status == "checking" then + return status, Strings("Checking..."), nil, false + elseif status == "downloading" then + local pct = st.progress and math.floor(st.progress * 100) or 0 + return status, Strings("Updating %d%%", pct), nil, false + elseif status == "available" then + return status, st.latest and (Strings("Update v") .. st.latest) + or Strings("Update"), function() pcall(imp.Check.download) end, true + elseif status == "ready" then + return status, Strings("Restart to update"), + function() require("src.core.HostShell").restart() end, true + elseif status == "needs_full" then + return status, Strings("Open releases"), + function() love.system.openURL(imp.Check.releaseUrl()) end, true + end + -- idle / uptodate / error: offer a manual check, with no glow. + return status, Strings("Check for updates"), + function() pcall(imp.Check.start) end, false +end -local function buildRomCard(imp, parent, m, version, info, ready, locked) +-- ------------------------------------------------------------ game panel + +local function buildRomCard(imp, x, y, w, m, version, info, ready, locked, maxH) local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.") or (imp.android and Strings("Copy the .gb/.gbc via USB.") or Strings("Or drop the .gb/.gbc file here.")) @@ -807,26 +522,47 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked) end end - local accent = version == "yellow" and "gold" or version - local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) - label(c, Strings("ROM"), 12 * m.s + 1, C("gray")) - label(c, romState, 15 * m.s + 2, C("white")) - label(c, romDetail, 12 * m.s + 2, C("detail")) + local pad = math.floor(14 * m.s) + local iw = w - 2 * pad + -- The card's fixed furniture always fits; the DETAIL text is the elastic + -- part, so it takes however many lines are left over. Without this the + -- card simply overflowed its budget and got clipped mid-button, which is + -- the failure a no-scroll layout has to design out rather than hope away. + local fixedH = pad + Kit.textHeight("caption") + math.floor(6 * m.s) + + Kit.textHeight("button") + math.floor(4 * m.s) + + math.floor(10 * m.s) + m.btnH + pad + local lineH = Kit.textHeight("small") + local maxLines = 3 + if maxH then + maxLines = math.max(0, math.min(3, math.floor((maxH - fixedH) / lineH))) + end + local detailH = Kit.wrapHeight("small", romDetail, iw, maxLines) + local h = fixedH + detailH + + Kit.card(x, y, w, h) + local cy = y + pad + cy = cy + Kit.caption(x + pad, cy, Strings("ROM")) + math.floor(6 * m.s) + Kit.text("button", Kit.ellipsize("button", romState, iw), x + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + cy = cy + Kit.textWrapped("small", romDetail, x + pad, cy, iw, PAL.detail, maxLines) + cy = cy + math.floor(10 * m.s) if romProgress ~= nil then - progressBar(c, romProgress, accent, math.max(8, 10 * m.s)) + Kit.progress(x + pad, cy + (m.btnH - math.floor(10 * m.s)) / 2, iw, + math.floor(10 * m.s), romProgress) else - button(imp, c, "rom-" .. version, romBtnLabel, { - w = "100%", h = m.btnH, size = 14 * m.s, - kind = romBtnEnabled and "neutral" or "disabled", + btn(imp, x + pad, cy, iw, m.btnH, "rom-" .. version, romBtnLabel, { + kind = "accent", + enabled = romBtnEnabled, action = romBtnEnabled and function() if imp.ready[version] then imp:reimport(version) else imp:choose(version) end end or nil, }) end + return h end -local function buildSaveFilesCard(imp, parent, m, version, ready, locked) +local function buildSaveFilesCard(imp, x, y, w, m, version, ready, locked, maxH) local sfImportEnabled, sfExportEnabled = false, false if not locked then imp:_ensureSlots(version) @@ -839,177 +575,197 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked) local sfNotice = (not locked) and imp.saveNotice[version] or nil local hintText, hintCol if sfNotice then - hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger") + hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red) elseif locked then - hintText, hintCol = Strings("Not available yet."), "warn" + hintText, hintCol = Strings("Not available yet."), PAL.muted else - hintText = imp:_savesDefaultHint(version) - hintCol = "warn" + hintText, hintCol = imp:_savesDefaultHint(version), PAL.muted end local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save") - local c = card(parent, { padding = m.cardPad, gap = 8 * m.s }) - label(c, Strings("SAVE FILES"), 12 * m.s + 1, C("gray")) - local row = mk({ parent = c, width = "100%", - positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) - -- explicit halves rather than flex growth, which mis-distributed inside - -- an auto-height card - local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2) - button(imp, row, "sav-import-" .. version, savImportLabel, { - w = halfW, h = m.btnH, size = 13 * m.s + 1, - kind = sfImportEnabled and "neutral" or "disabled", - action = sfImportEnabled and function() - imp:chooseSaveImport(version) - end or nil, - }) - button(imp, row, "sav-export-" .. version, Strings("Export save"), { - w = halfW, h = m.btnH, size = 13 * m.s + 1, - kind = sfExportEnabled and "neutral" or "disabled", - action = sfExportEnabled and function() - imp:exportSave(version) - end or nil, - }) - label(c, hintText, 12 * m.s + 1, C(hintCol)) - if sfNotice and sfNotice.dir then - local dir = sfNotice.dir - label(c, Strings("Open folder"), 12 * m.s + 1, - C("link", imp._hot["sav-folder-" .. version] and 1 or 0.85), { - onEvent = handler(imp, "sav-folder-" .. version, function() - love.system.openURL(imp:fileUrl(dir)) - end), - }) + local pad = math.floor(14 * m.s) + local iw = w - 2 * pad + -- Same elastic rule as the ROM card: the buttons and the caption are fixed, + -- the hint takes whatever lines remain (possibly none). + local folderRow = sfNotice and sfNotice.dir + local fixedH = pad + Kit.textHeight("caption") + math.floor(8 * m.s) + m.btnH + + math.floor(8 * m.s) + pad + + (folderRow and (math.floor(6 * m.s) + Kit.textHeight("small")) or 0) + local lineH = Kit.textHeight("small") + local maxLines = 3 + if maxH then + maxLines = math.max(0, math.min(3, math.floor((maxH - fixedH) / lineH))) end + local hintH = Kit.wrapHeight("small", hintText, iw, maxLines) + local h = fixedH + hintH + + Kit.card(x, y, w, h) + local cy = y + pad + cy = cy + Kit.caption(x + pad, cy, Strings("SAVE FILES")) + math.floor(8 * m.s) + local gap = math.floor(10 * m.s) + local halfW = math.floor((iw - gap) / 2) + btn(imp, x + pad, cy, halfW, m.btnH, "sav-import-" .. version, savImportLabel, { + kind = "accent", enabled = sfImportEnabled, + action = sfImportEnabled and function() imp:chooseSaveImport(version) end or nil, + }) + btn(imp, x + pad + halfW + gap, cy, halfW, m.btnH, "sav-export-" .. version, + Strings("Export save"), { + kind = "accent", enabled = sfExportEnabled, + action = sfExportEnabled and function() imp:exportSave(version) end or nil, + }) + cy = cy + m.btnH + math.floor(8 * m.s) + cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, maxLines) + if folderRow then + cy = cy + math.floor(6 * m.s) + local key = "sav-folder-" .. version + local label = Strings("Open folder") + local lw = Kit.textWidth("small", label) + local lh = Kit.textHeight("small") + Kit.focusable(key, x + pad, cy, lw, lh) + Kit.text("small", label, x + pad, cy, PAL.blue) + Theme.fill(x + pad, cy + lh - 1, lw, 1, PAL.blue, 0.6) + if Kit.press(x + pad, cy, lw, lh) or Kit._activateId == key then + local dir = sfNotice.dir + queueAction(imp, key, function() + love.system.openURL(imp:fileUrl(dir)) + end) + end + end + return h end -local function buildSlotCard(imp, parent, m, version) +-- Save slots, PAGINATED. This was a fixed-height scroller with momentum; it +-- is now a page of rows sized to whatever height the column has left, which +-- is why 40 slots cost exactly what 4 do. +local function buildSlotCard(imp, x, y, w, availH, m, version) imp:_ensureSlots(version) local slots = imp.slots[version] or {} local active = imp.activeSlot[version] local n = #slots + local pad = math.floor(14 * m.s) + local iw = w - 2 * pad + local gap = math.floor(8 * m.s) - local c = card(parent, { padding = m.cardPad, gap = 10 * m.s }) - local head = mk({ parent = c, width = "100%", - positioning = "flex", flexDirection = "horizontal", - justifyContent = "space-between", alignItems = "center" }) - label(head, Strings("SAVE SLOT"), 12 * m.s + 1, C("gray"), { textWrap = false }) - label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n), - 12 * m.s + 1, C("gray"), { textWrap = false }) + -- A slot row: name + LOADED tag, meta line, action buttons. + local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local rowH = math.floor(8 * m.s) + Kit.textHeight("button") + + math.floor(4 * m.s) + Kit.textHeight("small") + + math.floor(8 * m.s) + chipH + math.floor(8 * m.s) + + local headH = Kit.textHeight("caption") + math.floor(8 * m.s) + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local newBtnH = m.btnH + -- Rows get whatever is left after the card's fixed furniture. + local listH = availH - (pad * 2 + headH + pagerH + gap + newBtnH + gap) + local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12) + local pageKey = "slots-" .. version + local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage) + setPage(imp, pageKey, cur) + + local shown = math.max(0, last - first + 1) + local usedListH = (n == 0) and math.floor(70 * m.s) + or (shown * rowH + math.max(0, shown - 1) * gap) + local h = pad + headH + usedListH + gap + + (pages > 1 and (pagerH + gap) or 0) + newBtnH + pad + + Kit.card(x, y, w, h) + local cy = y + pad + Kit.caption(x + pad, cy, Strings("SAVE SLOT")) + Kit.textRight("small", n == 1 and Strings("1 slot") or Strings("%d slots", n), + x + w - pad, cy, PAL.muted) + cy = cy + headH if n == 0 then - local box = mk({ - parent = c, width = "100%", height = 90 * m.s, - border = 1, borderColor = C("border", 0.45), cornerRadius = 12, - positioning = "flex", justifyContent = "center", alignItems = "center", - padding = { horizontal = 12 }, - }) - label(box, Strings("No saves yet - start a new game or import one."), - 12 * m.s + 2, C("warn"), { textAlign = "center" }) - end + Kit.emptyBox(x + pad, cy, iw, usedListH, + Strings("No saves yet - start a new game or import one.")) + cy = cy + usedListH + gap + else + -- Wheel over the list turns pages; the page index is bounded, so there is + -- no scroll offset to interpolate and nothing to clamp against content. + setPage(imp, pageKey, + Kit.wheelPage(x + pad, cy, iw, usedListH, cur, n, perPage)) + for i = first, last do + local slot = slots[i] + local selected = slot.id == active + local rowKey = "slot-" .. version .. "-" .. slot.id + local ry = cy + (i - first) * (rowH + gap) + local ink = rowHit(imp, x + pad, ry, iw, rowH, selected, rowKey, + function() imp:_selectSlot(version, slot.id) end) - -- Taller stacked rows: name + LOADED line, meta line, then the action - -- buttons on their own full-width line, so no control can ever clip - -- against the card's right edge at any scale. Every line height is - -- measured, and the row height is their explicit sum: the engine's - -- auto-height came up short on some displays and let the button row fall - -- out of the card. - local chipSize = math.floor(11 * m.s + 1.5) - local nameSize = math.floor(14 * m.s + 2.5) - local metaSize = math.floor(11 * m.s + 2.5) - local pillSize = math.floor(10 * m.s + 1.5) - local btnH = math.ceil(textHeight(chipSize)) + 14 - local headH = math.max(math.ceil(textHeight(nameSize)), - math.ceil(textHeight(pillSize)) + 8) - local metaH = math.ceil(textHeight(metaSize)) - local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10 - -- Fixed-height scroller so 40 slots actually overflow (page-level flex - -- scroll alone was growing with content, leaving nothing to drag). - local listParent = c - if n > 0 then - local listH = math.floor(clamp(m.h * (m.twoCol and 0.58 or 0.42), 200, 720)) - listParent = mk({ - parent = c, id = "slots-" .. version, width = "100%", height = listH, - overflowY = "scroll", hideScrollbars = true, - positioning = "flex", flexDirection = "vertical", gap = 10 * m.s, - padding = { right = 4 }, - }) - end - for _, slot in ipairs(slots) do - local selected = slot.id == active - local rowKey = "slot-" .. version .. "-" .. slot.id - local row = mk({ - parent = listParent, width = "100%", height = rowH, - backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6), - border = 1, - borderColor = selected and C("green", 0.9) or C("border", 0.25), - cornerRadius = 12, - positioning = "flex", flexDirection = "vertical", gap = 5, - padding = { horizontal = 12, vertical = 10 }, - onEvent = handler(imp, rowKey, function() - imp:_selectSlot(version, slot.id) - end), - }) - local rowInner = row._innerW - local headRow = mk({ parent = row, width = rowInner, height = headH, - positioning = "flex", flexDirection = "horizontal", - justifyContent = "space-between", alignItems = "center" }) - local name = slot.label or slot.name or Strings("NEW GAME") - local pillW = selected - and (math.ceil(textWidth(pillSize, Strings("LOADED"))) + 20) or 0 - label(headRow, name, nameSize, C("white"), - { width = rowInner - pillW - 8, - textWrap = false, textOverflow = "ellipsis" }) - if selected then pill(headRow, Strings("LOADED"), "green", pillSize) end - local metaTxt - if slot.exists and slot.meta then - metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, - slot.meta.timeText or "0:00", slot.meta.dexCount or 0) - else - metaTxt = Strings("empty slot") - end - label(row, metaTxt, metaSize, C("warn"), - { width = rowInner, textWrap = false, textOverflow = "ellipsis" }) + local px = x + pad + math.floor(10 * m.s) + local inner = iw - math.floor(20 * m.s) + local ly = ry + math.floor(8 * m.s) + local name = slot.label or slot.name or Strings("NEW GAME") + local tagW = 0 + if selected then + tagW = Kit.textWidth("micro", Strings("LOADED")) + math.floor(16 * m.s) + Kit.tag(x + pad + iw - math.floor(10 * m.s) - tagW, ly, + tagW, Kit.textHeight("button"), Strings("LOADED"), + selected and PAL.inverse or PAL.green) + tagW = tagW + math.floor(8 * m.s) + end + Kit.text("button", Kit.ellipsize("button", name, inner - tagW), px, ly, ink) + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) + local metaTxt + if slot.exists and slot.meta then + metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, + slot.meta.timeText or "0:00", slot.meta.dexCount or 0) + else + metaTxt = Strings("empty slot") + end + Kit.text("small", Kit.ellipsize("small", metaTxt, inner), px, ly, + selected and PAL.inverse or PAL.muted) + ly = ly + Kit.textHeight("small") + math.floor(8 * m.s) - local btnRow = mk({ parent = row, width = rowInner, height = btnH, - positioning = "flex", flexDirection = "horizontal", - justifyContent = "flex-end", gap = 6 }) - if not imp.android then - button(imp, btnRow, rowKey .. "-rename", Strings("Rename"), { - size = chipSize, kind = "neutral", - action = function() imp:_beginRename(version, slot.id) end, + -- Action chips, right-aligned. A selected row is a white fill, so its + -- chips invert too or they would vanish. + local place = Layout.rightCluster(px, inner, math.floor(6 * m.s)) + local armed = deleteArmed(imp, "slot", slot.id, version) + -- Width pinned to the WIDER of the two captions so arming to "Sure?" + -- never reflows the row under the pointer (#433), and a translation + -- whose "delete" is shorter than its "sure?" is not clipped. + local delW = math.max(Kit.textWidth("small", DELETE_LABEL(false)), + Kit.textWidth("small", DELETE_LABEL(true))) + math.floor(20 * m.s) + btn(imp, place(delW), ly, delW, chipH, rowKey .. "-del", DELETE_LABEL(armed), { + kind = "danger", font = "small", keepArm = true, + action = function() + imp:pressDelete("slot", slot.id, version, function() + imp:_deleteSlot(version, slot.id) + end) + end, }) + if imp.onEditSave and slot.exists then + local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) + btn(imp, place(ew), ly, ew, chipH, rowKey .. "-edit", Strings("Edit"), { + kind = "accent", font = "small", + action = function() imp.onEditSave(version, slot.id) end, + }) + end + if not imp.android then + local rw = Kit.textWidth("small", Strings("Rename")) + math.floor(20 * m.s) + btn(imp, place(rw), ly, rw, chipH, rowKey .. "-rename", Strings("Rename"), { + kind = "accent", font = "small", + action = function() imp:_beginRename(version, slot.id) end, + }) + end end - if imp.onEditSave and slot.exists then - button(imp, btnRow, rowKey .. "-edit", Strings("Edit"), { - size = chipSize, kind = "accent", - action = function() imp.onEditSave(version, slot.id) end, - }) - end - local armed = deleteArmed(imp, "slot", slot.id, version) - -- width pinned so arming to "Sure?" never reflows the row under the - -- pointer (#433). Pinned to the wider of the two captions, not to the - -- unarmed one: English "Delete" is the longer of the pair, but a - -- translation need not keep that order (Japanese さくじょ is shorter than - -- よろしい?), and pinning to the shorter one clips the other. - button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), { - w = math.ceil(math.max(textWidth(chipSize, DELETE_LABEL(false)), - textWidth(chipSize, DELETE_LABEL(true)))) + 26, - size = chipSize, kind = armed and "dangerArmed" or "danger", - keepArm = true, - action = function() - imp:pressDelete("slot", slot.id, version, function() - imp:_deleteSlot(version, slot.id) - end) - end, - }) + cy = cy + usedListH + gap end - button(imp, c, "slot-new-" .. version, Strings("+ New save slot"), { - w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", - action = function() imp:_newSlot(version) end, - }) + if pages > 1 then + local newPage = Kit.pager(x + pad, cy, iw, cur, n, perPage, pageKey) + setPage(imp, pageKey, newPage) + cy = cy + pagerH + gap + end + btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. version, + Strings("+ New save slot"), { + kind = "good", + action = function() imp:_newSlot(version) end, + }) + return h end -local function buildGamePanel(imp, parent, m, version) +local function buildGamePanel(imp, x, y, w, availH, m, version) imp.panelVersion = version local info = GameVersion.info(version) local locked = info == nil @@ -1017,60 +773,155 @@ local function buildGamePanel(imp, parent, m, version) or tostring(version) local ready = (not locked) and imp.ready[version] or false - -- header: name + status pill - local head = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 12 * m.s }) - label(head, gameName, 22 * m.s + 4, C("white"), { textWrap = false }) - if ready then pill(head, Strings("GOOD TO GO"), "green", 11 * m.s + 1) - elseif locked then pill(head, Strings("COMING SOON"), "disabled", 11 * m.s + 1) - else pill(head, Strings("ROM REQUIRED"), "gold", 11 * m.s + 1) end + -- title + status tag + local titleH = Kit.textHeight("title") + Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading) + local tagText, tagCol + if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green + elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel + else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end + local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) + local tagH = Kit.textHeight("micro") + math.floor(10 * m.s) + Kit.tag(x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6)) + + math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol) + local cy = y + titleH + math.floor(12 * m.s) + local remaining = availH - (titleH + math.floor(12 * m.s)) - -- Two columns get explicit pixel widths (percentage children inside a - -- flex-grown column do not resolve, LayoutEngine LAY_004). Single-column - -- mode adds the cards straight to the page instead of nesting columns: - -- the engine under-measures a vertical column-of-columns' auto height, - -- which pushed the footer up over the save-slot card on phone shapes. - local grid, left, right + local gap = m.gap + local lx, lw, rx2, rw if m.twoCol then - grid = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - gap = m.colGap, alignItems = "flex-start" }) - left = mk({ parent = grid, width = m.colW, - positioning = "flex", flexDirection = "vertical", gap = 12 * m.s }) - right = mk({ parent = grid, width = m.colW, - positioning = "flex", flexDirection = "vertical" }) + lx, lw = x, m.colW + rx2, rw = x + m.colW + m.colGap, m.colW else - left, right = parent, parent + lx, lw, rx2, rw = x, w, x, w end - buildRomCard(imp, left, m, version, info, ready, locked) - buildSaveFilesCard(imp, left, m, version, ready, locked) - if imp.onEditTouchControls then - button(imp, left, "touch-controls", Strings("Touch Controls"), { - w = "100%", h = m.btnH, size = 13 * m.s + 1, kind = "neutral", - action = function() imp.onEditTouchControls() end, - }) - end - button(imp, left, "play-" .. version, + + -- LEFT COLUMN. The controls that must always be reachable -- Play, and the + -- control-reset pair -- are PINNED to the bottom of the column and laid out + -- upward; the informational cards fill downward from the top into whatever + -- is left. Without that pinning the column is a stack whose height depends + -- on how much text the ROM and save-file cards happen to carry, and at a + -- large UI scale on a short window the Play button is what falls off the + -- bottom -- the one thing that must never happen, and with no scrollbar to + -- rescue it. + local playH = math.max(m.btnH, math.floor(52 * m.s)) + local bottom = cy + remaining + local py = bottom - playH + btn(imp, lx, py, lw, playH, "play-" .. version, ready and (Strings("Play ") .. gameName) or (locked and Strings("Coming soon") or Strings("Import a ROM to play")), { - w = "100%", h = math.max(48, 52 * m.s), size = 18 * m.s + 2, - kind = ready and "primary" or "disabled", + kind = ready and "primary" or "ghost", font = "stat", + enabled = ready, action = ready and function() imp:play(version) end or nil, }) - if not locked then - buildSlotCard(imp, right, m, version) + if imp.controlsNotice then + local nh = Kit.wrapHeight("small", imp.controlsNotice.text, lw, 2) + py = py - nh - math.floor(4 * m.s) + Kit.textWrapped("small", imp.controlsNotice.text, lx, py, lw, + imp.controlsNotice.ok and PAL.green or PAL.red, 2) + end + -- Reset rebinds, directly under the touch controls. Rebinds are additive + -- (Input:applyBindings layers them over the defaults), so there is no + -- in-game way to undo one -- this is the way back. Two-press confirm, + -- same as every other destructive control here. + do + py = py - math.floor(6 * m.s) - m.btnH + local armed = deleteArmed(imp, "rebinds", "all", nil) + btn(imp, lx, py, lw, m.btnH, "reset-rebinds", + armed and Strings("Sure? Reset all rebinds") or Strings("Reset rebinds"), { + kind = "danger", font = "small", keepArm = true, + action = function() + imp:pressDelete("rebinds", "all", nil, function() + imp:_resetRebinds() + end) + end, + }) + end + if imp.onEditTouchControls then + py = py - math.floor(6 * m.s) - m.btnH + btn(imp, lx, py, lw, m.btnH, "touch-controls", Strings("Touch Controls"), { + kind = "accent", + action = function() imp.onEditTouchControls() end, + }) + end + + -- Cards fill the space above the pinned block, clipped so a long ROM error + -- message can never paint over the controls below it. + -- Split the leftover space between the two cards. The ROM card gets what + -- it needs up to half, the save-file card takes the rest; each trims its own + -- elastic text to fit. The clip is a backstop, not the mechanism. + local cardsH = py - gap - cy + Kit.pushClip(lx, cy, lw, math.max(0, cardsH)) + local ly = cy + -- In one column the save-slot card shares this region, so the two info + -- cards get a bounded share of it rather than the whole thing. + local infoBudget = m.twoCol and cardsH or math.floor(cardsH * 0.42) + local romH = buildRomCard(imp, lx, ly, lw, m, version, info, ready, locked, + math.floor(infoBudget * 0.55)) + ly = ly + romH + gap + local savH = buildSaveFilesCard(imp, lx, ly, lw, m, version, ready, locked, + infoBudget - romH - gap) + ly = ly + savH + gap + Kit.popClip() + + -- Save slots. Two columns put them beside the info cards; ONE column + -- stacks them underneath, in the space between those cards and the pinned + -- controls at the bottom. Placing them after the pinned block (the + -- obvious reading of "stack it under the left column") drew them off the + -- bottom of the window and over the footer, with no scrollbar to reach + -- them -- in a no-scroll layout, anything below the fold is simply gone. + if not locked then + local slotY = m.twoCol and cy or ly + local slotAvail = m.twoCol and remaining or (py - gap - ly) + if slotAvail > 80 * m.s then + Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) + buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version) + Kit.popClip() + end end - -- The right subtree may have grown after FlexLove last measured its - -- grandparent. Refresh only the desktop grid once both columns are complete. - if grid then refreshAutoHeight(grid) end end --- ------- mods panel +-- --------------------------------------------------------------- mods panel -local function buildModsPanel(imp, parent, m) +-- The sort row both mod panels share, including its persisted choice. +local function sortChips(imp, x, y, w, m, prefix) + local sortKey = imp.modSort or "name" + if imp.modSort == nil then + local ok, opts = pcall(require("src.core.SaveData").loadOptions) + if ok and type(opts) == "table" and type(opts.modSort) == "string" then + sortKey = opts.modSort + imp.modSort = sortKey + end + end + local defs = { + { key = "name", label = Strings("Name") }, + { key = "popularity", label = Strings("Popularity") }, + { key = "release", label = Strings("Release date") }, + { key = "updated", label = Strings("Last updated") }, + } + local items = {} + for _, s in ipairs(defs) do + items[#items + 1] = { + label = s.label, active = sortKey == s.key, key = prefix .. s.key, + action = function() + imp.modSort = s.key + pcall(function() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.modSort = s.key + SaveData.saveOptions(opts) + end) + end, + } + end + local labW = Kit.textWidth("small", Strings("Sort:")) + math.floor(8 * m.s) + Kit.text("small", Strings("Sort:"), x, y + math.floor(6 * m.s), PAL.detail) + return sortKey, chipRow(imp, x + labW, y, w - labW, m, items) +end + +local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") local mods = imp.mods or {} @@ -1078,302 +929,220 @@ local function buildModsPanel(imp, parent, m) for _, mod in ipairs(mods) do if mod.enabled then enabledCount = enabledCount + 1 end end + local gap = m.gap + local cy = y - local title = Strings("Mods") - local count = Strings("%d of %d enabled", enabledCount, #mods) + -- header: title, count, actions + local titleH = Kit.textHeight("title") + Kit.text("title", Strings("Mods"), x, cy, PAL.heading) + Kit.text("small", Strings("%d of %d enabled", enabledCount, #mods), + x + Kit.textWidth("title", Strings("Mods")) + math.floor(12 * m.s), + cy + titleH - Kit.textHeight("small") - 2, PAL.muted) + local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + local bh = m.btnH local importLabel = imp:_modsImportButtonLabel() - local items = { - { size = 22 * m.s + 4, text = title }, - { size = 12 * m.s + 2, text = count }, - { size = 13 * m.s + 1, text = importLabel, btn = true }, - } + local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s) + btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseMod() end }) if #mods > 0 then - items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Enable all"), btn = true } - items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Disable all"), btn = true } + local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s) + btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), { + kind = "warn", font = "small", + action = function() imp:_setAllMods(false) end }) + local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s) + btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), { + kind = "good", font = "small", + action = function() imp:_setAllMods(true) end }) end - local head, buttonsRow = headRows(parent, m, items) - label(head, title, 22 * m.s + 4, C("white"), { textWrap = false }) - label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false }) - local btnRow = buttonsRow() - if #mods > 0 then - button(imp, btnRow, "mods-enable-all", Strings("Enable all"), { - size = 11 * m.s + 1, kind = "neutral", - action = function() imp:_setAllMods(true) end, - }) - button(imp, btnRow, "mods-disable-all", Strings("Disable all"), { - size = 11 * m.s + 1, kind = "neutral", - action = function() imp:_setAllMods(false) end, - }) - end - button(imp, btnRow, "mods-import", importLabel, { - h = m.btnH, size = 13 * m.s + 1, kind = "neutral", - action = function() imp:chooseMod() end, - }) + cy = cy + math.max(titleH, bh) + math.floor(8 * m.s) + -- notice line + local noticeText, noticeCol if imp.modNotice then - label(parent, imp.modNotice.text, 12 * m.s + 2, - C(imp.modNotice.ok and "green" or "danger")) + noticeText = imp.modNotice.text + noticeCol = imp.modNotice.ok and PAL.green or PAL.red else - label(parent, imp:_modsDefaultHint(), 12 * m.s + 2, C("warn")) + noticeText, noticeCol = imp:_modsDefaultHint(), PAL.muted end + cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2) + + math.floor(8 * m.s) if #mods == 0 then - local box = mk({ - parent = parent, width = "100%", height = 110 * m.s, - backgroundColor = C("card", 0.4), - border = 1, borderColor = C("border", 0.3), cornerRadius = 14, - positioning = "flex", justifyContent = "center", alignItems = "center", - padding = { horizontal = 16 }, - }) - label(box, imp:_modsEmptyHint(), - math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" }) + Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) return end - -- Sort row: Name / Popularity / Release date / Last updated. The choice - -- persists in options.modSort; data-less mods (no github field, or a - -- cache that predates the feature) sink to the bottom of data sorts. - local sortKey = imp.modSort or "name" - if imp.modSort == nil then - local ok, opts = pcall(require("src.core.SaveData").loadOptions) - if ok and type(opts) == "table" and type(opts.modSort) == "string" then - sortKey = opts.modSort - imp.modSort = sortKey - end - end - local sortRow = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - flexWrap = "wrap", alignItems = "center", gap = 6 * m.s }) - label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false }) - local sorts = { - { key = "name", label = Strings("Name") }, - { key = "popularity", label = Strings("Popularity") }, - { key = "release", label = Strings("Release date") }, - { key = "updated", label = Strings("Last updated") }, - } - for _, s in ipairs(sorts) do - local active = sortKey == s.key - local key = "mod-sort-" .. s.key - mk({ - parent = sortRow, text = s.label, - textColor = active and C("green") - or (imp._hot[key] and C("white") or C("detail")), - textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, - backgroundColor = active and C("green", 0.18) or C("border", 0.10), - border = 1, - borderColor = active and C("green", 0.6) or C("border", 0.35), - cornerRadius = 999, - padding = { horizontal = 10, vertical = 4 }, - onEvent = handler(imp, key, function() - imp.modSort = s.key - pcall(function() - local SaveData = require("src.core.SaveData") - local opts = SaveData.loadOptions() - opts.modSort = s.key - SaveData.saveOptions(opts) - end) - end), - }) - end + local sortKey + sortKey, cy = (function() + local k, h = sortChips(imp, x, cy, w, m, "mod-sort-") + return k, cy + h + math.floor(8 * m.s) + end)() - -- Immediate mode rebuilds this panel every frame; re-sorting the whole - -- mod list per frame (with lowercased-string allocations in the - -- comparator) fed the GC for nothing. Cache the sorted array, keyed on - -- the list identity/length, the sort mode, and the update-info revision - -- that _syncModUpdateInfo bumps when release data changes. + -- Immediate mode paints this panel every frame; re-sorting the whole list + -- per frame (with lowercased-string allocations in the comparator) fed the + -- GC for nothing. Cache the sorted array, keyed on the list identity, the + -- sort mode, and the update-info revision the fetch pump bumps. local cache = imp._modSortCache if cache and cache.src == mods and cache.n == #mods and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then mods = cache.list else - local sorted = {} - for i, v in ipairs(mods) do sorted[i] = v end - table.sort(sorted, function(a, b) - local function value(mod) - if sortKey == "name" then return (mod.name or ""):lower() end - local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - if sortKey == "popularity" then - return info and info.downloads and info.downloads.total or -1 + local sorted = {} + for i, v in ipairs(mods) do sorted[i] = v end + table.sort(sorted, function(a, b) + local function value(mod) + if sortKey == "name" then return (mod.name or ""):lower() end + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + if sortKey == "popularity" then + return info and info.downloads and info.downloads.total or -1 + end + local date = info and info.dates + if sortKey == "release" then return date and date.first or "0000-00-00" end + return date and date.latest or "0000-00-00" end - local date = info and info.dates - if sortKey == "release" then - return date and date.first or "0000-00-00" + local va, vb = value(a), value(b) + if va ~= vb then + if sortKey == "name" then return va < vb end + return va > vb -- data sorts newest / most popular first end - return date and date.latest or "0000-00-00" - end - local va, vb = value(a), value(b) - if va ~= vb then - if sortKey == "name" then return va < vb end - return va > vb -- data sorts newest / most popular first - end - return (a.name or ""):lower() < (b.name or ""):lower() - end) - imp._modSortCache = { src = mods, n = #mods, key = sortKey, - rev = imp._modUpdateRev or 0, list = sorted } - mods = sorted + return (a.name or ""):lower() < (b.name or ""):lower() + end) + imp._modSortCache = { src = imp.mods, n = #mods, key = sortKey, + rev = imp._modUpdateRev or 0, list = sorted } + mods = sorted end - -- Explicit column widths AND heights: a flex-grown container collapses - -- its children's layout in this engine, and card auto-height came up - -- short on some displays, dropping the bottom action row out of the card. - -- Everything is measured with the same integer-sized fonts the labels - -- render with, and the card gets the exact sum. - local innerW = m.contentW - 32 - local clusterW = math.max(96, math.floor(110 * m.s)) - local bodyW = innerW - clusterW - 10 - local nameSize = math.floor(15 * m.s + 2.5) - local smallSize = math.floor(12 * m.s + 1.5) - local badgeSize = math.floor(10 * m.s + 1.5) - local chipSize = math.floor(11 * m.s + 1.5) - local btnH = math.ceil(textHeight(chipSize)) + 14 - local badgeH = math.ceil(textHeight(badgeSize)) + 6 - local toggleH = math.floor(24 * m.s + 2) + 8 - local pillH = math.ceil(textHeight(chipSize)) + 8 - local clusterH = pillH + 6 + toggleH - for _, mod in ipairs(mods) do + -- A mod row is a fixed height: name line, version + status line, one line + -- of description, and an action row. Fixed because a page of uniform rows + -- is what lets perPage come from the viewport. + local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + -- Text block on the left, chips right-aligned beside it: one row, not a + -- text block with a button strip stacked under it. + local textH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") + local rowH = math.floor(8 * m.s) + math.max(textH, chipH) + + math.floor(8 * m.s) + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local listH = availH - (cy - y) - pagerH - gap + local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20) + local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage) + setPage(imp, "mods", cur) + local listTop = cy + setPage(imp, "mods", + Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)) + + for i = first, last do + local mod = mods[i] + local ry = listTop + (i - first) * (rowH + gap) + local key = "mod-" .. mod.id + Kit.card(x, ry, w, rowH) + local pad = math.floor(12 * m.s) + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(10 * m.s) + + -- name + badge + enable toggle (right) + local togW = math.floor(56 * m.s) + local togH = math.floor(26 * m.s) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - local checkLine, checkCol - if info and info.status == "available" then - checkLine = Strings("Checked for updates - v%s available", - tostring(info.latest)) - checkCol = "green" - elseif info and info.status == "current" then - checkLine, checkCol = Strings("Checked for updates - up to date"), "green" - elseif info and info.status == "error" then - checkLine, checkCol = Strings("Checked for updates - failed"), "danger" - elseif mod.github and mod.github ~= "" then - checkLine, checkCol = Strings("Not checked for updates yet"), "warn" + + -- Right cluster first (toggle, then the action chips), all on one + -- vertically-centred line, so the text block knows the width it has left. + local chipY = ry + (rowH - chipH) / 2 + local place2 = Layout.rightCluster(px, inner, math.floor(6 * m.s)) + local togKey = "mod-toggle-" .. mod.id + -- The toggle reports its own new value, but the importer owns the state: + -- queue the flip and let _toggleMod (which may raise an experimental-mod + -- confirm) decide what actually happens. + local _, flipped = Kit.toggle(place2(togW), + ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey) + if flipped then + queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end) end - -- Total downloads plus first/latest release dates, all from the same - -- cached release fetch. Only shown once that data actually carries - -- counts, so a pre-downloads cache entry costs the line, not a wrong "0". - local dlLine + local chipsW = togW + math.floor(6 * m.s) + + local armed = deleteArmed(imp, "mod", mod.id, nil) + local delW = math.max(Kit.textWidth("small", DELETE_LABEL(false)), + Kit.textWidth("small", DELETE_LABEL(true))) + math.floor(20 * m.s) + btn(imp, place2(delW), chipY, delW, chipH, "mod-del-" .. mod.id, + DELETE_LABEL(armed), { + kind = "danger", font = "small", keepArm = true, + action = function() + imp:pressDelete("mod", mod.id, nil, function() + imp:_deleteMod(mod.id) + end) + end, + }) + chipsW = chipsW + delW + math.floor(6 * m.s) + if mod.github and mod.github ~= "" then + local vw = Kit.textWidth("small", Strings("Versions")) + math.floor(20 * m.s) + btn(imp, place2(vw), chipY, vw, chipH, "mod-ver-" .. mod.id, + Strings("Versions"), { kind = "accent", font = "small", + action = function() imp:_modGithubAction(mod.id, "versions") end }) + local updLabel, updKind = Strings("Check for updates"), "ghost" + if info and info.status == "available" then + updLabel, updKind = Strings("Update"), "warn" + elseif info and info.status == "current" then + updLabel = Strings("Check again") + end + local uw = Kit.textWidth("small", updLabel) + math.floor(20 * m.s) + btn(imp, place2(uw), chipY, uw, chipH, "mod-upd-" .. mod.id, updLabel, { + kind = updKind, font = "small", + action = function() imp:_modGithubAction(mod.id, "update") end }) + chipsW = chipsW + vw + uw + math.floor(12 * m.s) + end + local textW = inner - chipsW - math.floor(12 * m.s) + + local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) + local nameShown = Kit.ellipsize("button", mod.name, + textW - badgeW - math.floor(8 * m.s)) + Kit.text("button", nameShown, px, ly, PAL.heading) + Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly, + badgeW, Kit.textHeight("button"), mod.badge, + mod.experimental and PAL.yellow or PAL.muted) + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) + + -- version + status + update state + local statusText, statusCol = modStatusColor(mod.status) + local line = "v" .. tostring(mod.version or "?") .. " " .. statusText + Kit.text("small", line, px, ly, statusCol) + local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) + if imp:_modInfoPending(mod.id) then + -- An inline spinner, because this row's release check is genuinely in + -- flight -- the list stays usable while it resolves. + Loader.dot(lx, ly, Kit.textHeight("small")) + Kit.text("small", Strings("Checking..."), + lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) + elseif info and info.status == "available" then + Kit.text("small", Strings("v%s available", tostring(info.latest)), + lx, ly, PAL.yellow) + elseif info and info.status == "current" then + Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) + elseif info and info.status == "error" then + Kit.text("small", Strings("check failed"), lx, ly, PAL.red) + end + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + + -- one line of description, or the download stats when we have them + local sub = mod.description or "" if info and info.downloads then local d = info.dates - dlLine = ModUpdate.statsLine(info.downloads.total, - d and d.first, d and d.latest) + sub = ModUpdate.statsLine(info.downloads.total, d and d.first, + d and d.latest) end - - -- measure the body: name (with the badge beside it only when it fits), - -- version, check line, wrapped description - local badgeW = math.ceil(textWidth(badgeSize, mod.badge)) + 14 - local nameH = math.ceil(textHeight(nameSize)) - local badgeBesideName = - math.ceil(textWidth(nameSize, mod.name)) + 8 + badgeW <= bodyW - local bodyH = badgeBesideName and math.max(nameH, badgeH) - or (nameH + 4 + badgeH) - bodyH = bodyH + 4 + math.ceil(textHeight(smallSize)) - if checkLine then - bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW) + if sub ~= "" then + Kit.text("small", Kit.ellipsize("small", sub, textW), px, ly, PAL.detail) end - if dlLine then - bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW) - end - if mod.description ~= "" then - bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW) - end - local rowH = math.max(bodyH, clusterH) - - -- how many lines the right-aligned action row needs - local btnRowW = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26 - local updLabel, updKind = Strings("Check for updates"), "neutral" - if info and info.status == "available" then - updLabel, updKind = Strings("Update"), "accent" - elseif info and info.status == "current" then - updLabel = Strings("Check again") - end - if mod.github and mod.github ~= "" then - btnRowW = btnRowW + math.ceil(textWidth(chipSize, updLabel)) + 26 + 6 - + math.ceil(textWidth(chipSize, Strings("Versions"))) + 26 + 6 - end - local btnLines = math.max(1, math.ceil(btnRowW / innerW)) - local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 - - local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) - local row = mk({ parent = c, width = "100%", height = rowH, - positioning = "flex", flexDirection = "horizontal", - gap = 10, alignItems = "flex-start" }) - local body = mk({ parent = row, width = bodyW, height = rowH, - positioning = "flex", flexDirection = "vertical", gap = 4 }) - local function badge(parent2) - mk({ - parent = parent2, text = mod.badge, autoScaleText = false, - textColor = mod.experimental and C("gold") or C("warn"), - textSize = badgeSize, textAlign = "center-center", - width = badgeW, height = badgeH, - border = 1, borderColor = C("border", 0.5), cornerRadius = 5, - }) - end - if badgeBesideName then - local nameRow = mk({ parent = body, width = "100%", - height = math.max(nameH, badgeH), - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 8 }) - label(nameRow, mod.name, nameSize, C("white"), { textWrap = false }) - badge(nameRow) - else - label(body, mod.name, nameSize, C("white"), - { width = "100%", textWrap = false, textOverflow = "ellipsis" }) - badge(body) - end - label(body, "v" .. tostring(mod.version or "?"), smallSize, C("detail")) - if checkLine then - label(body, checkLine, smallSize, C(checkCol), { width = "100%" }) - end - if dlLine then - label(body, dlLine, smallSize, C("gold"), { width = "100%" }) - end - if mod.description ~= "" then - label(body, mod.description, smallSize, C("detail"), { width = "100%" }) - end - - local cluster = mk({ parent = row, width = clusterW, height = clusterH, - positioning = "flex", flexDirection = "vertical", - alignItems = "flex-end", gap = 6 }) - local chipText, chipCol = modStatusChip(mod.status) - pill(cluster, chipText, chipCol, chipSize) - local togKey = "mod-toggle-" .. mod.id - local togWrap = mk({ parent = cluster, - width = math.floor(46 * m.s + 4) + 8, height = toggleH, - padding = 4, - onEvent = handler(imp, togKey, function() imp:_toggleMod(mod.id) end), - }) - toggleSwitch(togWrap, mod.enabled, math.floor(46 * m.s + 4), - math.floor(24 * m.s + 2), "tog:" .. mod.id) - - local btnRow = mk({ parent = c, width = "100%", - height = btnLines * btnH + (btnLines - 1) * 6, - positioning = "flex", flexDirection = "horizontal", - justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) - if mod.github and mod.github ~= "" then - button(imp, btnRow, "mod-upd-" .. mod.id, updLabel, { - size = chipSize, kind = updKind, - action = function() imp:_modGithubAction(mod.id, "update") end, - }) - button(imp, btnRow, "mod-ver-" .. mod.id, Strings("Versions"), { - size = chipSize, kind = "neutral", - action = function() imp:_modGithubAction(mod.id, "versions") end, - }) - end - local armed = deleteArmed(imp, "mod", mod.id, nil) - button(imp, btnRow, "mod-del-" .. mod.id, DELETE_LABEL(armed), { - w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26, - size = chipSize, kind = armed and "dangerArmed" or "danger", - keepArm = true, - action = function() - imp:pressDelete("mod", mod.id, nil, function() - imp:_deleteMod(mod.id) - end) - end, - }) end + + local pagerY = listTop + (last - first + 1) * (rowH + gap) + local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") + setPage(imp, "mods", newPage) end --- ------- find mods panel +-- ---------------------------------------------------------- find mods panel -local function buildFindPanel(imp, parent, m) - imp._findThumbFetched = false - imp._findStatsFetched = false +local function buildFindPanel(imp, x, y, w, availH, m) imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") @@ -1381,371 +1150,270 @@ local function buildFindPanel(imp, parent, m) local sources = imp.findSources or {} local rows = imp:_findRows() local total = #((imp.findIndex and imp.findIndex.mods) or {}) + local gap = m.gap + local cy = y - local title = Strings("Find Mods") - local count = (#sources > 0) and ((#rows == total) and Strings("%d mods listed", total) - or Strings("%d of %d mods", #rows, total)) or nil + -- header + local titleH = Kit.textHeight("title") + Kit.text("title", Strings("Find Mods"), x, cy, PAL.heading) + if #sources > 0 then + local count = (#rows == total) and Strings("%d mods listed", total) + or Strings("%d of %d mods", #rows, total) + Kit.text("small", count, + x + Kit.textWidth("title", Strings("Find Mods")) + math.floor(12 * m.s), + cy + titleH - Kit.textHeight("small") - 2, PAL.muted) + end + local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + local bh = m.btnH local addLabel = (#sources == 0) and Strings("Add an index") or Strings("Add index") - local items = { - { size = 22 * m.s + 4, text = title }, - { size = 13 * m.s + 1, text = addLabel, btn = true }, - } - if count then items[#items + 1] = { size = 12 * m.s + 2, text = count } end + local aw = Kit.textWidth("small", addLabel) + math.floor(24 * m.s) + btn(imp, place(aw), cy, aw, bh, "find-add", addLabel, { + kind = "accent", font = "small", + action = function() imp:_promptAddIndex() end }) if #sources > 0 then - items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true } - end - local head, buttonsRow = headRows(parent, m, items) - label(head, title, 22 * m.s + 4, C("white"), { textWrap = false }) - if count then - label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false }) - end - local btnRow = buttonsRow() - if #sources > 0 then - button(imp, btnRow, "find-refresh", Strings("Refresh"), { - h = m.btnH, size = 13 * m.s + 1, kind = "neutral", + local rw = Kit.textWidth("small", Strings("Refresh")) + math.floor(24 * m.s) + btn(imp, place(rw), cy, rw, bh, "find-refresh", Strings("Refresh"), { + kind = "accent", font = "small", action = function() imp._findSearchFocus = false imp:_disarmTextInput() imp:_refreshFind(true) - end, - }) + end }) end - button(imp, btnRow, "find-add", addLabel, { - h = m.btnH, size = 13 * m.s + 1, kind = "neutral", - action = function() imp:_promptAddIndex() end, - }) + cy = cy + math.max(titleH, bh) + math.floor(8 * m.s) + local noticeText, noticeCol if imp.findNotice then - label(parent, imp.findNotice.text, 12 * m.s + 2, - C(imp.findNotice.ok and "green" or "danger")) + noticeText = imp.findNotice.text + noticeCol = imp.findNotice.ok and PAL.green or PAL.red else - label(parent, Strings( - "Mods here are listed, not reviewed - read the source and trust the author."), - 12 * m.s + 2, C("warn")) + noticeText = Strings( + "Mods here are listed, not reviewed - read the source and trust the author.") + noticeCol = PAL.muted end + cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2) + + math.floor(8 * m.s) if #sources == 0 then - local box = mk({ - parent = parent, width = "100%", height = 150 * m.s, - border = 1, borderColor = C("border", 0.45), cornerRadius = 14, - positioning = "flex", flexDirection = "vertical", - justifyContent = "center", alignItems = "center", gap = 6 * m.s, - padding = { horizontal = 20 }, - }) - label(box, Strings("No mod index added"), 15 * m.s + 2, C("white"), - { textAlign = "center", width = "100%" }) - label(box, Strings( + local h = math.floor(140 * m.s) + Kit.card(x, cy, w, h) + Kit.textCenter("button", Strings("No mod index added"), x, + cy + math.floor(40 * m.s), w, PAL.heading) + Kit.textWrapped("small", Strings( "Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."), - 12 * m.s + 1, C("warn"), { textAlign = "center", width = "100%" }) + x + math.floor(24 * m.s), cy + math.floor(70 * m.s), + w - math.floor(48 * m.s), PAL.muted, 3) return end + -- source rows for _, source in ipairs(sources) do - local srow = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 8 * m.s }) - label(srow, source.label or source.feed, 12 * m.s + 1, C("detail"), - { width = m.contentW - - (math.ceil(textWidth(11 * m.s + 1, Strings("Remove"))) + 26) - - 8 * m.s, - textWrap = false, textOverflow = "ellipsis" }) - button(imp, srow, "find-src-rm-" .. tostring(source.feed), Strings("Remove"), { - size = 11 * m.s + 1, kind = "danger", - action = function() imp:_removeIndex(source.feed) end, - }) + local h = math.max(Kit.tapMin(), math.floor(28 * m.s)) + local rmW = Kit.textWidth("small", Strings("Remove")) + math.floor(20 * m.s) + Kit.text("small", Kit.ellipsize("small", source.label or source.feed, + w - rmW - math.floor(12 * m.s)), x, cy + (h - Kit.textHeight("small")) / 2, + PAL.detail) + local feed = source.feed + btn(imp, x + w - rmW, cy, rmW, h, "find-src-rm-" .. tostring(feed), + Strings("Remove"), { kind = "danger", font = "small", + action = function() imp:_removeIndex(feed) end }) + cy = cy + h + math.floor(4 * m.s) end + cy = cy + math.floor(4 * m.s) - -- search field (hand-rolled text state, same routing as the rename modal) - textField(imp, parent, "find-search", - imp.findQuery or "", Strings("Search mods"), - imp._findSearchFocus == true, - function() - imp:_toggleFindSearchFocus() - end) + -- search field + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + textField(imp, x, cy, w, fieldH, "find-search", imp.findQuery or "", + Strings("Search mods"), imp._findSearchFocus == true, + function() imp:_toggleFindSearchFocus() end) + cy = cy + fieldH + math.floor(8 * m.s) + -- category chips local cats = (imp.findIndex and imp.findIndex.categories) or {} if #cats > 0 then - local catRow = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - flexWrap = "wrap", gap = 6 * m.s }) - local function catChip(name, id, active) - local key = "find-cat-" .. id - mk({ - parent = catRow, text = name, - textColor = active and C("green") - or (imp._hot[key] and C("white") or C("detail")), - textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, - backgroundColor = active and C("green", 0.18) or C("border", 0.10), - border = 1, - borderColor = active and C("green", 0.6) or C("border", 0.35), - cornerRadius = 999, - padding = { horizontal = 10, vertical = 4 }, - onEvent = handler(imp, key, function() - imp.findCategory = (id ~= "" and imp.findCategory ~= id) and id or nil - end), - }) - end - catChip(Strings("All"), "", imp.findCategory == nil) + local items = { { + label = Strings("All"), active = imp.findCategory == nil, + key = "find-cat-all", action = function() imp.findCategory = nil end, + } } for _, cat in ipairs(cats) do - catChip(cat, cat, imp.findCategory == cat) + items[#items + 1] = { + label = cat, active = imp.findCategory == cat, + key = "find-cat-" .. cat, + action = function() + imp.findCategory = (imp.findCategory ~= cat) and cat or nil + setPage(imp, "find", 1) + end, + } end + cy = cy + chipRow(imp, x, cy, w, m, items) + math.floor(8 * m.s) end if #rows == 0 then - local box = mk({ - parent = parent, width = "100%", height = 130 * m.s, - backgroundColor = C("card", 0.4), - border = 1, borderColor = C("border", 0.3), cornerRadius = 14, - positioning = "flex", flexDirection = "vertical", - justifyContent = "center", alignItems = "center", gap = 8, - padding = { horizontal = 20 }, - }) - mk({ parent = box, width = math.floor(30 * m.s), - height = math.floor(30 * m.s), - image = imp._findIcon, objectFit = "contain", - imageTint = C("gray", 0.6) }) - label(box, (total == 0) and Strings("This index lists no mods yet.") - or Strings("No mods match that search."), - math.floor(13 * m.s + 1.5), C("detail"), { textAlign = "center" }) - if total > 0 then - label(box, - Strings("Try a different search, or clear the category filter."), - math.floor(11 * m.s + 1.5), C("warn"), { textAlign = "center" }) - end + Kit.emptyBox(x, cy, w, math.floor(110 * m.s), + (total == 0) and Strings("This index lists no mods yet.") + or Strings("No mods match that search.")) return end - -- Sort row: Name / Popularity / Release date / Last updated, the same - -- options the MODS tab offers, sharing its persisted choice - -- (options.modSort). Data comes from the same _findStats resolution the - -- cards use (feed-published, else the repo fetch); rows whose stats have - -- not resolved yet sink to the bottom of data sorts and rise as the - -- one-per-frame fetches complete. - local sortKey = imp.modSort or "name" - if imp.modSort == nil then - local ok, opts = pcall(require("src.core.SaveData").loadOptions) - if ok and type(opts) == "table" and type(opts.modSort) == "string" then - sortKey = opts.modSort - imp.modSort = sortKey - end - end - local sortRow = mk({ parent = parent, width = "100%", - positioning = "flex", flexDirection = "horizontal", - flexWrap = "wrap", alignItems = "center", gap = 6 * m.s }) - label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false }) - local sorts = { - { key = "name", label = Strings("Name") }, - { key = "popularity", label = Strings("Popularity") }, - { key = "release", label = Strings("Release date") }, - { key = "updated", label = Strings("Last updated") }, - } - for _, s in ipairs(sorts) do - local active = sortKey == s.key - local key = "find-sort-" .. s.key - mk({ - parent = sortRow, text = s.label, - textColor = active and C("green") - or (imp._hot[key] and C("white") or C("detail")), - textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, - backgroundColor = active and C("green", 0.18) or C("border", 0.10), - border = 1, - borderColor = active and C("green", 0.6) or C("border", 0.35), - cornerRadius = 999, - padding = { horizontal = 10, vertical = 4 }, - onEvent = handler(imp, key, function() - imp.modSort = s.key - pcall(function() - local SaveData = require("src.core.SaveData") - local opts = SaveData.loadOptions() - opts.modSort = s.key - SaveData.saveOptions(opts) - end) - end), - }) - end + local sortKey + sortKey, cy = (function() + local k, h = sortChips(imp, x, cy, w, m, "find-sort-") + return k, cy + h + math.floor(8 * m.s) + end)() - local sorted = {} - for i, v in ipairs(rows) do sorted[i] = v end - table.sort(sorted, function(a, b) - local function value(entry) - if sortKey == "name" then - return (entry.title or entry.id or ""):lower() + -- Same caching rule as the MODS tab: the comparator allocates, so only + -- re-sort when the inputs actually change. + local fcache = imp._findSortCache + if fcache and fcache.src == rows and fcache.key == sortKey + and fcache.rev == (imp._findStatsRev or 0) then + rows = fcache.list + else + local sorted = {} + for i, v in ipairs(rows) do sorted[i] = v end + table.sort(sorted, function(a, b) + local function value(entry) + if sortKey == "name" then return (entry.title or entry.id or ""):lower() end + local stats = imp:_findStats(entry) + if sortKey == "popularity" then return stats and stats.total or -1 end + if sortKey == "release" then return stats and stats.first or "0000-00-00" end + return stats and stats.latest or "0000-00-00" end - local stats = imp:_findStats(entry) - if sortKey == "popularity" then - return stats and stats.total or -1 + local va, vb = value(a), value(b) + if va ~= vb then + if sortKey == "name" then return va < vb end + return va > vb end - if sortKey == "release" then - return stats and stats.first or "0000-00-00" - end - return stats and stats.latest or "0000-00-00" - end - local va, vb = value(a), value(b) - if va ~= vb then - if sortKey == "name" then return va < vb end - return va > vb -- data sorts newest / most popular first - end - return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower() - end) - rows = sorted + return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower() + end) + imp._findSortCache = { src = rows, key = sortKey, + rev = imp._findStatsRev or 0, list = sorted } + rows = sorted + end local installed = imp:_findInstalledMap() - local thumbW = 64 * m.s - -- Explicit measured widths AND heights, same reasoning as the mods card: - -- the engine's card auto-height dropped the Details/Source/Install row - -- past the card's bottom edge on some displays. - local innerW = m.contentW - 32 - local bodyW = innerW - thumbW - 10 - local titleSize = math.floor(15 * m.s + 2.5) - local smallSize = math.floor(12 * m.s + 1.5) - local chipSize = math.floor(11 * m.s + 1.5) - local btnH = math.ceil(textHeight(chipSize)) + 14 - for _, entry in ipairs(rows) do + -- The thumbnail sits BESIDE the text and the action chips share the title + -- line's row, so a card is only as tall as its text block. The old layout + -- stacked chips under a 64px thumbnail and got ~2 rows per screen; this + -- fits roughly twice as many without shrinking a single tap target. + local thumb = math.floor(44 * m.s) + local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + -- TWO text lines, not three: the version/author/category meta and the + -- download stats share a line. A third line cost every row ~20px, which + -- at this UI scale was the difference between one and two rows per page. + local textH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + local rowH = math.floor(8 * m.s) + math.max(thumb, textH, chipH) + + math.floor(8 * m.s) + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local listH = availH - (cy - y) - pagerH - gap + local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20) + local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage) + setPage(imp, "find", cur) + local listTop = cy + setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage)) + + for i = first, last do + local entry = rows[i] + local ry = listTop + (i - first) * (rowH + gap) + Kit.card(x, ry, w, rowH) + local pad = math.floor(12 * m.s) + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(8 * m.s) + + -- Action chips are right-aligned on the SAME rows as the text, so the + -- card needs no separate button strip. Reserve their width first. + local chipY = ry + (rowH - chipH) / 2 + local place2 = Layout.rightCluster(px, inner, math.floor(6 * m.s)) local action, note = findActionFor(entry, installed[entry.id]) - -- Release stats for the row: feed-published when the feed carries - -- them, otherwise fetched from the mod's GitHub repo (one per frame, - -- cached six hours) exactly like the MODS tab does. - local stats = imp:_findStats(entry) - local statsLine - if stats and (stats.total ~= nil or stats.first or stats.latest) then - statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest) - end - - local bodyH = math.ceil(textHeight(titleSize)) - + 4 + math.ceil(textHeight(smallSize)) - if statsLine then bodyH = bodyH + 4 + wrapHeight(smallSize, statsLine, bodyW) end - if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end - if entry.summary and entry.summary ~= "" then - bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW) - end - local rowH = math.max(thumbW, bodyH) - local btnRowW = math.ceil(textWidth(chipSize, Strings("Details"))) + 26 - if entry.repo then - btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, Strings("Source"))) + 26 - end + local chipsW = 0 if action then - btnRowW = btnRowW + 6 + math.ceil(textWidth(chipSize, action)) + 26 + local iw4 = Kit.textWidth("small", action) + math.floor(20 * m.s) + btn(imp, place2(iw4), chipY, iw4, chipH, "find-inst-" .. entry.id, action, { + kind = "accent", font = "small", + action = function() imp:_findConfirmInstall(entry) end }) + chipsW = chipsW + iw4 + math.floor(6 * m.s) else - btnRowW = btnRowW + 6 - + math.ceil(textWidth(chipSize, Strings("Unavailable"))) + 20 + local uw = Kit.textWidth("micro", Strings("Unavailable")) + math.floor(16 * m.s) + Kit.tag(place2(uw), chipY, uw, chipH, Strings("Unavailable"), PAL.yellow) + chipsW = chipsW + uw + math.floor(6 * m.s) end - local btnLines = math.max(1, math.ceil(btnRowW / innerW)) - local cardH = 28 + rowH + 8 + btnLines * btnH + (btnLines - 1) * 6 + if entry.repo then + local sw = Kit.textWidth("small", Strings("Source")) + math.floor(20 * m.s) + local repo = entry.repo + btn(imp, place2(sw), chipY, sw, chipH, "find-repo-" .. entry.id, + Strings("Source"), { kind = "accent", font = "small", + action = function() love.system.openURL(repo) end }) + chipsW = chipsW + sw + math.floor(6 * m.s) + end + local dw = Kit.textWidth("small", Strings("Details")) + math.floor(20 * m.s) + btn(imp, place2(dw), chipY, dw, chipH, "find-det-" .. entry.id, + Strings("Details"), { kind = "accent", font = "small", + action = function() imp:_findShowDetails(entry) end }) + chipsW = chipsW + dw + math.floor(12 * m.s) - local c = card(parent, { padding = m.cardPad, gap = 8, height = cardH }) - local row = mk({ parent = c, width = "100%", height = rowH, - positioning = "flex", flexDirection = "horizontal", - gap = 10, alignItems = "flex-start" }) + -- thumbnail (or its placeholder while the async fetch is in flight) local image = imp:_findThumb(entry) if image then - mk({ parent = row, image = image, objectFit = "contain", - width = thumbW, height = thumbW, cornerRadius = 8 }) + local iw3, ih3 = image:getDimensions() + local s = math.min(thumb / iw3, thumb / ih3) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, Theme.snap(px), Theme.snap(ly), 0, s, s) else - mk({ parent = row, width = thumbW, height = thumbW, - backgroundColor = C("border", 0.18), cornerRadius = 8, - text = "MOD", textColor = C("disabled"), - textSize = math.floor(10 * m.s + 1.5), textAlign = "center-center", - autoScaleText = false }) + Theme.stroke(px, ly, thumb, thumb, PAL.line, Theme.A.hairline, 1) + Kit.textCenter("micro", "MOD", px, + ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint) end - local body = mk({ parent = row, width = bodyW, height = rowH, - positioning = "flex", flexDirection = "vertical", gap = 4 }) - label(body, entry.title or entry.id, titleSize, C("white"), - { width = "100%", textWrap = false, textOverflow = "ellipsis" }) + + local bx = px + thumb + math.floor(10 * m.s) + local bw = inner - thumb - math.floor(10 * m.s) - chipsW + Kit.text("button", Kit.ellipsize("button", entry.title or entry.id, bw), + bx, ly, PAL.heading) + local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s) local meta = "v" .. tostring(ModIndex.displayVersion(entry)) if entry.author then meta = meta .. " - " .. entry.author end if entry.categories and entry.categories[1] then meta = meta .. " - " .. entry.categories[1] end - label(body, meta, smallSize, C("detail"), - { width = "100%", textWrap = false, textOverflow = "ellipsis" }) - if statsLine then - label(body, statsLine, smallSize, C("gold"), { width = "100%" }) - end - if note then label(body, note, smallSize, C("green"), { width = "100%" }) end - if entry.summary and entry.summary ~= "" then - label(body, entry.summary, smallSize, C("detail"), { width = "100%" }) - end - - local btnRow = mk({ parent = c, width = "100%", - height = btnLines * btnH + (btnLines - 1) * 6, - positioning = "flex", flexDirection = "horizontal", - justifyContent = "flex-end", flexWrap = "wrap", gap = 6 }) - if not action then - pill(btnRow, Strings("Unavailable"), "gold", chipSize) - end - button(imp, btnRow, "find-det-" .. entry.id, Strings("Details"), { - size = chipSize, kind = "neutral", - action = function() imp:_findShowDetails(entry) end, - }) - if entry.repo then - button(imp, btnRow, "find-repo-" .. entry.id, Strings("Source"), { - size = chipSize, kind = "neutral", - action = function() love.system.openURL(entry.repo) end, - }) - end - if action then - button(imp, btnRow, "find-inst-" .. entry.id, action, { - size = chipSize, kind = "accent", - action = function() imp:_findConfirmInstall(entry) end, - }) + -- meta and stats on one line, stats first because they are what the + -- Popularity/date sorts are ordering by + local stats = imp:_findStats(entry) + local tail = entry.summary or "" + if stats and (stats.total ~= nil or stats.first or stats.latest) then + tail = ModUpdate.statsLine(stats.total, stats.first, stats.latest) end + if note then tail = note .. " - " .. tail end + local line2 = meta + if tail ~= "" then line2 = line2 .. " - " .. tail end + Kit.text("small", Kit.ellipsize("small", line2, bw), bx, by2, + note and PAL.green or PAL.detail) end + + local pagerY = listTop + (last - first + 1) * (rowH + gap) + setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find")) end --- ------- updater banner + footer - -local function buildBanner(imp, parent, m) - if not imp.Check then return end - local ok, st = pcall(imp.Check.state) - st = (ok and type(st) == "table") and st or nil - local status = st and st.status - if status ~= "available" and status ~= "downloading" - and status ~= "ready" and status ~= "needs_full" then - return - end - local c = card(parent, { - padding = { horizontal = 16, vertical = 12 }, - borderColor = C("gold", 0.5), gap = 8 * m.s, - }) - local row = mk({ parent = c, width = "100%", - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 10 * m.s }) - if status == "downloading" then - label(row, Strings("Downloading update"), 13 * m.s + 1, C("detail"), - { flex = 1 }) - progressBar(c, st.progress, "gold", math.max(8, 10 * m.s)) - else - local msg, btnLabel, action - if status == "available" then - msg = st.latest and (Strings("Update v") .. st.latest .. Strings(" available")) - or Strings("An update is available") - btnLabel = Strings("Update") - action = function() pcall(imp.Check.download) end - elseif status == "needs_full" then - msg = Strings("A new version needs a fresh download") - btnLabel = Strings("Open releases") - action = function() love.system.openURL(imp.Check.releaseUrl()) end - else - msg = Strings("Update downloaded") - btnLabel = Strings("Restart to update") - action = function() require("src.core.HostShell").restart() end - end - label(row, msg, 13 * m.s + 1, C("white"), { flex = 1 }) - button(imp, row, "updater", btnLabel, { - h = m.btnH, size = 13 * m.s, kind = "primary", action = action, - }) - end -end +-- ------------------------------------------------------------------ footer local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. "or a link from the discord that bryanthaboi himself posted, just know " .. "it might have been tampered with. go to the discord to verify " .. COMMUNITY_URL .. " (or click the logo above)" -local function buildFooter(imp, parent, m) - mk({ parent = parent, width = "100%", height = 1, - backgroundColor = C("border", 0.18) }) - -- The BCG mark is dark ink; invert it to white for the dark panel. +-- Pinned to the bottom of the window; returns the y it starts at, so the +-- panels above know how much room they have. +-- Deliberately compact: at a large UI scale the footer is pure overhead +-- competing with the panel for a short window's height, so the mark and the +-- link share one line and the trust warning is capped at a single line. +local function footerHeight(imp, m) + local bh = math.floor(22 * m.s) + return bh + math.floor(4 * m.s) + Kit.textHeight("micro") + + math.floor(8 * m.s) +end + +local function buildFooter(imp, m, y) + Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) + local cy = y + math.floor(8 * m.s) + -- The BCG mark is dark ink; invert it for the black field. imp.invertShader = imp.invertShader or love.graphics.newShader([[ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 p = Texel(tex, tc); @@ -1753,291 +1421,369 @@ local function buildFooter(imp, parent, m) } ]]) local bw, bh = imp.bcg:getDimensions() - local scale = math.min((180 * m.s) / bw, (44 * m.s) / bh) - mk({ - parent = parent, width = bw * scale, height = bh * scale, - alignSelf = "center", - customDraw = function(el) - love.graphics.setShader(imp.invertShader) - love.graphics.setColor(1, 1, 1, imp._hot.bcg and 1 or 0.85) - love.graphics.draw(imp.bcg, el.x, el.y, 0, - el.width / bw, el.height / bh) - love.graphics.setShader() - end, - onEvent = handler(imp, "bcg", function() - love.system.openURL(COMMUNITY_URL) - end), - }) - label(parent, TRUST_WARNING, 10 * m.s + 2, C("warn"), - { width = "100%", textAlign = "center" }) - -- the link gets a full-width, center-aligned row of its own: alignSelf on - -- an auto-width label was not honored and left it hugging the margin - local linkRow = mk({ parent = parent, width = "100%", - positioning = "flex", justifyContent = "center", - padding = { bottom = 18 } }) - label(linkRow, COMMUNITY_URL, 11 * m.s + 2, - C("link", imp._hot.bois and 1 or 0.85), { - textWrap = false, - onEvent = handler(imp, "bois", function() - love.system.openURL(COMMUNITY_URL) - end), - }) + local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh) + local dw, dh = bw * scale, bh * scale + -- Mark on the left of the line, link on the right, so the footer is one row + -- instead of three stacked ones. + local linkW = Kit.textWidth("small", COMMUNITY_URL) + local bx = m.contentX + local hot = Kit.hover(bx, cy, dw, dh) + love.graphics.setShader(imp.invertShader) + love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) + love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(cy), 0, scale, scale) + love.graphics.setShader() + love.graphics.setColor(1, 1, 1, 1) + if Kit.press(bx, cy, dw, dh) then + queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) + end + local lx = m.contentX + m.contentW - linkW + local lyy = cy + (dh - Kit.textHeight("small")) / 2 + Kit.text("small", COMMUNITY_URL, lx, lyy, PAL.blue) + Theme.fill(lx, lyy + Kit.textHeight("small") - 1, linkW, 1, PAL.blue, 0.6) + if Kit.press(lx, lyy, linkW, Kit.textHeight("small")) then + queueAction(imp, "bois", function() love.system.openURL(COMMUNITY_URL) end) + end + cy = cy + dh + math.floor(4 * m.s) + Kit.text("micro", Kit.ellipsize("micro", TRUST_WARNING, m.contentW), + m.contentX, cy, PAL.muted) end --- ------- modals +-- ------------------------------------------------------------------ modals +-- A modal draws its own scrim, then raises Kit.blockClicks so everything +-- underneath is inert, then lowers it for its own panel. There is no +-- z-ordered hit test, so this ordering IS the z-order. -local function modalOverlay(imp, m, closeKey, onClose) - local overlay = mk({ - z = 1000, - x = 0, y = 0, width = m.W, height = m.H, - backgroundColor = rgba(4, 6, 16, 0.72), - positioning = "flex", justifyContent = "center", alignItems = "center", - onEvent = onClose and handler(imp, closeKey, onClose) or function() end, - }) - return overlay +local function modalPanel(m, w, h) + Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.82) + Kit.blockClicks = true + local pw = math.floor(math.min(w, m.W - 2 * m.pad)) + local ph = math.floor(math.min(h, m.H - 2 * m.pad)) + local px = math.floor((m.W - pw) / 2) + local py = math.floor((m.H - ph) / 2) + Kit.card(px, py, pw, ph, true) + Kit.blockClicks = false + return px, py, pw, ph end -local function modalPanel(overlay, m, w, props) - local p = { - parent = overlay, - width = math.min(w, m.W - 24), - backgroundColor = rgba(12, 17, 38, 0.98), - border = 1, borderColor = C("border", 0.5), - cornerRadius = 12, - positioning = "flex", flexDirection = "vertical", - gap = 10 * m.s, padding = { horizontal = 16, vertical = 14 }, - -- swallow clicks so the overlay's close handler stays outside the panel - onEvent = function() end, - } - for k, v in pairs(props or {}) do p[k] = v end - return mk(p) -end - --- Shared prompt: title, hand-rolled text field, hint, action row. +-- Shared prompt: title, read-only field over the importer's text, buttons. local function buildPrompt(imp, m, spec) - local overlay = modalOverlay(imp, m, spec.key .. "-out") - local panel = modalPanel(overlay, m, spec.w or 460 * m.s) - label(panel, spec.title, 15 * m.s + 2, C("white")) + local pad = math.floor(18 * m.s) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local w = math.floor(460 * m.s) + local hintH = spec.hint and (Kit.wrapHeight("small", spec.hint, + w - 2 * pad, 2) + math.floor(6 * m.s)) or 0 + local footH = spec.footnote and (Kit.textHeight("micro") + + math.floor(8 * m.s)) or 0 + local h = pad + Kit.textHeight("button") + math.floor(10 * m.s) + hintH + + fieldH + math.floor(12 * m.s) + m.btnH + footH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", spec.title, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) if spec.hint then - label(panel, spec.hint, 12 * m.s + 1, C("detail")) + cy = cy + Kit.textWrapped("small", spec.hint, px + pad, cy, + pw - 2 * pad, PAL.detail, 2) + math.floor(6 * m.s) end - textField(imp, panel, spec.key .. "-field", spec.text or "", nil, true) - local btnRow = mk({ parent = panel, width = "100%", - positioning = "flex", flexDirection = "horizontal", - justifyContent = "flex-end", gap = 8 * m.s }) + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, spec.key .. "-field", + spec.text or "", nil, true) + cy = cy + fieldH + math.floor(12 * m.s) + + local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s)) + local okW = Kit.textWidth("small", spec.okLabel or Strings("Save")) + + math.floor(28 * m.s) + btn(imp, place(okW), cy, okW, m.btnH, spec.key .. "-ok", + spec.okLabel or Strings("Save"), + { kind = "primary", font = "small", action = spec.commit }) + local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s) + btn(imp, place(cw), cy, cw, m.btnH, spec.key .. "-cancel", Strings("Cancel"), + { font = "small", action = spec.cancel }) if spec.paste then - button(imp, btnRow, spec.key .. "-paste", Strings("Paste"), { - size = 12 * m.s + 1, kind = "accent", action = spec.paste, - }) - mk({ parent = btnRow, flex = 1 }) + local pwid = Kit.textWidth("small", Strings("Paste")) + math.floor(28 * m.s) + btn(imp, px + pad, cy, pwid, m.btnH, spec.key .. "-paste", Strings("Paste"), + { kind = "accent", font = "small", action = spec.paste }) end - button(imp, btnRow, spec.key .. "-cancel", Strings("Cancel"), { - size = 12 * m.s + 1, kind = "neutral", action = spec.cancel, - }) - button(imp, btnRow, spec.key .. "-ok", spec.okLabel or Strings("Save"), { - size = 12 * m.s + 1, kind = "primary", action = spec.commit, - }) + cy = cy + m.btnH + math.floor(8 * m.s) if spec.footnote then - label(panel, spec.footnote, 11 * m.s + 1, C("warn")) + Kit.text("micro", spec.footnote, px + pad, cy, PAL.muted) end end local function buildConfirmModal(imp, m) local c = imp._modConfirm - local overlay = modalOverlay(imp, m, "confirm-out") - -- roomier than the shared 420 default: the install confirm carries the - -- compat issue list and the trust warning, and those lines need air - local panel = modalPanel(overlay, m, 520 * m.s, { - gap = 12 * m.s, padding = { horizontal = 22, vertical = 20 }, - }) - label(panel, c.title or Strings("Confirm"), 17 * m.s + 2, C("white")) + local pad = math.floor(22 * m.s) + local w = math.floor(520 * m.s) + local lineH = Kit.textHeight("small") + math.floor(4 * m.s) + local h = pad + Kit.textHeight("stat") + math.floor(12 * m.s) + + #(c.lines or {}) * lineH + math.floor(12 * m.s) + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("stat", c.title or Strings("Confirm"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("stat") + math.floor(12 * m.s) for _, line in ipairs(c.lines or {}) do - label(panel, line, 13 * m.s + 1, C("detail")) + Kit.text("small", Kit.ellipsize("small", line, pw - 2 * pad), + px + pad, cy, PAL.detail) + cy = cy + lineH end - -- explicit height: an auto-sized row measures short while the panel - -- auto-sizes, which clipped the buttons at the panel's bottom border - local btnRow = mk({ parent = panel, width = "100%", height = m.btnH, - positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s }) - button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), { - flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary", - action = function() - imp._modConfirm = nil - if c.indexEntry then - imp:_findInstall(c.indexEntry) - elseif c.kind == "update" then - imp:_confirmModUpdate(c.id, c.release) - elseif c.kind == "enableAll" then - imp:_setAllMods(true, true) - else - imp:_toggleMod(c.id, true) - end - end, - }) - button(imp, btnRow, "confirm-no", Strings("Cancel"), { - flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "neutral", - action = function() imp._modConfirm = nil end, - }) + cy = cy + math.floor(12 * m.s) + local gap = math.floor(10 * m.s) + local halfW = math.floor((pw - 2 * pad - gap) / 2) + btn(imp, px + pad, cy, halfW, m.btnH, "confirm-yes", + c.yesLabel or Strings("OK"), { + kind = "primary", font = "small", + action = function() + imp._modConfirm = nil + if c.indexEntry then + imp:_findInstall(c.indexEntry) + elseif c.kind == "update" then + imp:_confirmModUpdate(c.id, c.release) + elseif c.kind == "enableAll" then + imp:_setAllMods(true, true) + else + imp:_toggleMod(c.id, true) + end + end, + }) + btn(imp, px + pad + halfW + gap, cy, halfW, m.btnH, "confirm-no", + Strings("Cancel"), { font = "small", + action = function() imp._modConfirm = nil end }) end -local function buildTextModal(imp, m, title, body, closeFn, scrollId) - local overlay = modalOverlay(imp, m, "textmodal-out") - local panel = modalPanel(overlay, m, 520 * m.s) - label(panel, title, 15 * m.s + 2, C("white")) - local scroller = mk({ - parent = panel, id = scrollId, width = "100%", - height = math.min(m.H * 0.5, 340 * m.s), - overflowY = "scroll", hideScrollbars = true, - positioning = "flex", flexDirection = "vertical", - padding = { right = 8 }, - }) - label(scroller, body, 12 * m.s + 1, C("detail"), { width = "100%" }) - button(imp, panel, "textmodal-close", Strings("Close"), { - w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", - action = closeFn, - }) +-- A body of text, paginated rather than scrolled (release notes, mod +-- descriptions). Long-form text is the one place a scrollbar was genuinely +-- convenient, so the pager here moves a LINE window instead of a row window. +local function buildTextModal(imp, m, key, title, body, closeFn) + local pad = math.floor(18 * m.s) + local w = math.floor(520 * m.s) + local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s)) + local px, py, pw, ph = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad), + px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) + + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) + - pagerH - math.floor(8 * m.s) + local lineH = Kit.textHeight("small") + local perPage = math.max(1, math.floor(bodyH / lineH)) + local lines = Kit.wrapLines("small", body, pw - 2 * pad) or { "" } + local first, last, cur = Kit.pageBounds(page(imp, key), #lines, perPage) + setPage(imp, key, cur) + setPage(imp, key, Kit.wheelPage(px, cy, pw, bodyH, cur, #lines, perPage)) + for i = first, last do + Kit.text("small", lines[i], px + pad, cy + (i - first) * lineH, PAL.detail) + end + cy = cy + bodyH + math.floor(8 * m.s) + setPage(imp, key, Kit.pager(px + pad, cy, pw - 2 * pad, cur, #lines, + perPage, key)) + cy = cy + pagerH + math.floor(10 * m.s) + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, key .. "-close", + Strings("Close"), { font = "small", action = closeFn }) end local function buildVersionsModal(imp, m) local ModUpdate = require("src.mods.ModUpdate") local v = imp._modVersions - local overlay = modalOverlay(imp, m, "versions-out") - local panel = modalPanel(overlay, m, 520 * m.s) - label(panel, Strings("Other versions: ") .. tostring(v.name), - 15 * m.s + 2, C("white")) + local pad = math.floor(18 * m.s) + local w = math.floor(520 * m.s) + local h = math.floor(math.min(m.H - 2 * m.pad, 480 * m.s)) + local px, py, pw, ph = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Kit.ellipsize("button", + Strings("Other versions: ") .. tostring(v.name), pw - 2 * pad), + px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(6 * m.s) + local info = imp:_modUpdateInfo(v.id) local statusTxt = Strings("Installed: v") .. tostring(v.current) - local statusCol = "detail" + local statusCol = PAL.detail if info and info.status == "available" then statusTxt = statusTxt .. " - " .. Strings("Update v") .. tostring(info.latest) - statusCol = "green" + statusCol = PAL.yellow elseif info and info.status == "current" then statusTxt = statusTxt .. " - " .. Strings("Up to date") - statusCol = "green" + statusCol = PAL.green end - label(panel, statusTxt, 12 * m.s + 1, C(statusCol)) - local scroller = mk({ - parent = panel, id = "modversions", width = "100%", - height = math.min(m.H * 0.5, 320 * m.s), overflowY = "scroll", hideScrollbars = true, - positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, - padding = { right = 8 }, - }) - for i, rel in ipairs(v.releases) do - local row = mk({ parent = scroller, width = "100%", - backgroundColor = C("bg", 0.5), - border = 1, borderColor = C("border", 0.35), cornerRadius = 8, - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 8 * m.s, - padding = { horizontal = 10, vertical = 8 } }) + Kit.text("small", statusTxt, px + pad, cy, statusCol) + cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) + + local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s)) + local rowH = math.floor(8 * m.s) + Kit.textHeight("small") + + math.floor(4 * m.s) + chipH + math.floor(8 * m.s) + local gap = math.floor(6 * m.s) + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) + - pagerH - math.floor(8 * m.s) + local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12) + local n = #v.releases + local first, last, cur = Kit.pageBounds(page(imp, "versions"), n, perPage) + setPage(imp, "versions", cur) + setPage(imp, "versions", + Kit.wheelPage(px, cy, pw, listH, cur, n, perPage)) + + for i = first, last do + local rel = v.releases[i] + local ry = cy + (i - first) * (rowH + gap) + Theme.stroke(px + pad, ry, pw - 2 * pad, rowH, PAL.line, Theme.A.hairline, 1) + local ix = px + pad + math.floor(10 * m.s) + local inner = pw - 2 * pad - math.floor(20 * m.s) local text = "v" .. rel.version if rel.version == v.current then text = text .. Strings(" (installed)") end if rel.prerelease then text = text .. " pre" end - local body = mk({ parent = row, flex = 1, - positioning = "flex", flexDirection = "vertical", gap = 2 * m.s }) - label(body, text, 12 * m.s + 1, - rel.version == v.current and C("warn") or C("white"), - { textWrap = false }) + Kit.text("small", text, ix, ry + math.floor(8 * m.s), + rel.version == v.current and PAL.yellow or PAL.heading) local preview = ModUpdate.previewLine(rel.body or "", 90) if preview ~= "" then - label(body, preview, 11 * m.s + 1, C("detail"), - { textWrap = false, textOverflow = "ellipsis" }) + Kit.text("micro", Kit.ellipsize("micro", preview, + inner - math.floor(180 * m.s)), + ix + Kit.textWidth("small", text) + math.floor(10 * m.s), + ry + math.floor(8 * m.s), PAL.muted) + end + local ly = ry + math.floor(8 * m.s) + Kit.textHeight("small") + + math.floor(4 * m.s) + local place = Layout.rightCluster(ix, inner, math.floor(6 * m.s)) + if rel.version ~= v.current then + local iw5 = Kit.textWidth("small", Strings("Install")) + math.floor(20 * m.s) + btn(imp, place(iw5), ly, iw5, chipH, "ver-inst-" .. i, Strings("Install"), { + kind = "accent", font = "small", + action = function() imp:_installModVersion(v.id, rel) end }) end if type(rel.body) == "string" and rel.body:match("%S") then - button(imp, row, "ver-notes-" .. i, Strings("Read more"), { - size = 11 * m.s, kind = "neutral", + local rw = Kit.textWidth("small", Strings("Read more")) + math.floor(20 * m.s) + btn(imp, place(rw), ly, rw, chipH, "ver-notes-" .. i, Strings("Read more"), { + kind = "accent", font = "small", action = function() - imp._modReleaseNotes = { version = rel.version, - body = rel.body or "", scroll = 0 } - end, - }) - end - if rel.version ~= v.current then - button(imp, row, "ver-inst-" .. i, Strings("Install"), { - size = 11 * m.s, kind = "accent", - action = function() imp:_installModVersion(v.id, rel) end, - }) + imp._modReleaseNotes = { version = rel.version, body = rel.body or "" } + end }) end end - button(imp, panel, "versions-close", Strings("Close"), { - w = "100%", h = m.btnH, size = 13 * m.s, kind = "neutral", - action = function() imp._modVersions = nil end, - }) + cy = cy + listH + math.floor(8 * m.s) + setPage(imp, "versions", + Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "versions")) + cy = cy + pagerH + math.floor(10 * m.s) + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "versions-close", + Strings("Close"), { font = "small", + action = function() imp._modVersions = nil end }) end local function buildSettingsModal(imp, m) local model = imp._settings - local overlay = modalOverlay(imp, m, "settings-out") - local panel = modalPanel(overlay, m, 640 * m.s, { - height = math.min(m.H - 40, m.H * 0.88), - }) - local head = mk({ parent = panel, width = "100%", - positioning = "flex", flexDirection = "horizontal", - justifyContent = "space-between", alignItems = "center" }) - label(head, Strings("Settings"), 17 * m.s + 3, C("white"), { textWrap = false }) - button(imp, head, "settings-close", Strings("Close"), { - size = 12 * m.s + 1, kind = "neutral", - action = function() imp:_closeSettings() end, - }) - label(panel, Strings( + local pad = math.floor(18 * m.s) + local w = math.floor(640 * m.s) + local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9)) + local px, py, pw, ph = modalPanel(m, w, h) + local cy = py + pad + + Kit.text("stat", Strings("Settings"), px + pad, cy, PAL.heading) + local cw = Kit.textWidth("small", Strings("Close")) + math.floor(24 * m.s) + btn(imp, px + pw - pad - cw, cy, cw, m.btnH, "settings-close", + Strings("Close"), { font = "small", + action = function() imp:_closeSettings() end }) + cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s) + Kit.text("micro", Strings( "Saved to your options file; the game applies these on its next start."), - 11 * m.s + 2, C("warn")) - local scroller = mk({ - parent = panel, id = "settings-scroll", width = "100%", - flex = 1, overflowY = "scroll", hideScrollbars = true, - positioning = "flex", flexDirection = "vertical", gap = 6 * m.s, - padding = { right = 8 }, - }) - for si, section in ipairs(model.sections) do - label(scroller, section.title, 12 * m.s + 2, C("gray"), { - width = "100%", - margin = { top = si == 1 and 0 or 12 }, - }) - for ri, row in ipairs(section.rows) do - local key = "set-" .. si .. "-" .. ri - local rowEl = mk({ parent = scroller, width = "100%", - backgroundColor = C("rowBg", 0.6), - border = 1, borderColor = C("border", 0.22), cornerRadius = 8, - positioning = "flex", flexDirection = "horizontal", - alignItems = "center", gap = 8 * m.s, - padding = { horizontal = 12, vertical = 8 } }) - label(rowEl, row.label, 13 * m.s + 1, C("white"), - { flex = 1, textWrap = false, textOverflow = "ellipsis" }) + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) + + -- Settings rows are PAGINATED, flattened across sections so a page is a + -- uniform run of rows. Section titles ride along as their own entry. + local flat = imp._settingsFlat + if not flat or flat.model ~= model then + flat = { model = model } + for _, section in ipairs(model.sections) do + flat[#flat + 1] = { header = section.title } + for _, row in ipairs(section.rows) do + flat[#flat + 1] = { row = row } + end + end + imp._settingsFlat = flat + end + + local rowH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local gap = math.floor(4 * m.s) + local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local listH = (py + ph - pad) - cy - pagerH - math.floor(8 * m.s) + local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 24) + local n = #flat + -- POKEPORT_LAUNCHER_SETTINGS_PAGE jumps straight to a page, so a shot can + -- capture a row that is not on page one. + local wanted = tonumber(os.getenv("POKEPORT_LAUNCHER_SETTINGS_PAGE") or "") + if wanted and not imp._settingsPaged then + imp._settingsPaged = true + setPage(imp, "settings", wanted) + end + local first, last, cur = Kit.pageBounds(page(imp, "settings"), n, perPage) + setPage(imp, "settings", cur) + setPage(imp, "settings", Kit.wheelPage(px, cy, pw, listH, cur, n, perPage)) + + for i = first, last do + local item = flat[i] + local ry = cy + (i - first) * (rowH + gap) + if item.header then + Kit.caption(px + pad, ry + (rowH - Kit.textHeight("caption")) / 2, + item.header) + else + local row = item.row + local key = "set-" .. i + Theme.stroke(px + pad, ry, pw - 2 * pad, rowH, PAL.line, + Theme.A.hairline, 1) + local ix = px + pad + math.floor(12 * m.s) + local inner = pw - 2 * pad - math.floor(24 * m.s) + local ly = ry + (rowH - Kit.textHeight("small")) / 2 if row.editText then - label(rowEl, row.value(), 13 * m.s + 1, C("detail"), { textWrap = false }) - button(imp, rowEl, key .. "-edit", Strings("Edit"), { - size = 11 * m.s + 1, kind = "accent", - action = function() - imp._settingsText = { row = row, text = tostring(row.value() or ""), - maxLen = row.editText.maxLen } - imp:_armTextInput() - end, - }) + local ew = Kit.textWidth("small", Strings("Edit")) + math.floor(20 * m.s) + local vw = math.floor(160 * m.s) + Kit.text("small", Kit.ellipsize("small", row.label, + inner - ew - vw - math.floor(20 * m.s)), ix, ly, PAL.text) + Kit.textRight("small", Kit.ellipsize("small", tostring(row.value()), vw), + ix + inner - ew - math.floor(10 * m.s), ly, PAL.detail) + btn(imp, ix + inner - ew, ry + (rowH - m.btnH) / 2, ew, m.btnH, + key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", + action = function() + imp._settingsText = { row = row, text = tostring(row.value() or ""), + maxLen = row.editText.maxLen } + imp:_armTextInput() + end }) + elseif row.action then + -- A plain action row (Reset rebinds): the whole right side is one + -- button rather than a value ladder. + local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) + + math.floor(24 * m.s) + Kit.text("small", Kit.ellipsize("small", row.label, + inner - aw - math.floor(12 * m.s)), ix, ly, PAL.text) + btn(imp, ix + inner - aw, ry + (rowH - m.btnH) / 2, aw, m.btnH, + key .. "-act", row.actionLabel or Strings("Run"), { + kind = row.danger and "danger" or "ghost", font = "small", + action = function() + if row.action() ~= false then model.save() end + end }) else - button(imp, rowEl, key .. "-prev", "<", { - size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, - action = function() - if row.step and row.step(-1) then model.save() end - end, - }) - label(rowEl, row.value(), 13 * m.s + 1, C("green"), { - width = 110 * m.s, textAlign = "center", textWrap = false, - }) - button(imp, rowEl, key .. "-next", ">", { - size = 13 * m.s + 1, kind = "neutral", pad = { horizontal = 10, vertical = 4 }, - action = function() - if row.step and row.step(1) then model.save() end - end, - }) + local stepW = math.floor(34 * m.s) + local valW = math.floor(140 * m.s) + Kit.text("small", Kit.ellipsize("small", row.label, + inner - 2 * stepW - valW - math.floor(24 * m.s)), ix, ly, PAL.text) + local rx = ix + inner + btn(imp, rx - stepW, ry + (rowH - m.btnH) / 2, stepW, m.btnH, + key .. "-next", ">", { font = "small", + action = function() if row.step and row.step(1) then model.save() end end }) + Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), valW), + rx - stepW - valW, ly, valW, PAL.heading) + btn(imp, rx - stepW - valW - stepW, ry + (rowH - m.btnH) / 2, stepW, + m.btnH, key .. "-prev", "<", { font = "small", + action = function() if row.step and row.step(-1) then model.save() end end }) end end end + cy = cy + listH + math.floor(8 * m.s) + setPage(imp, "settings", + Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings")) end local function buildModals(imp, m) if imp._settingsText then local st = imp._settingsText buildPrompt(imp, m, { - key = "settext", title = st.row.label, - text = st.text, + key = "settext", title = st.row.label, text = st.text, okLabel = Strings("Save"), commit = function() imp:_commitSettingsText() end, cancel = function() @@ -2046,17 +1792,13 @@ local function buildModals(imp, m) end, footnote = Strings("Enter to save - Esc to cancel"), }) - return - end - if imp._settings then - buildSettingsModal(imp, m) - return + return true end + if imp._settings then buildSettingsModal(imp, m) return true end if imp._rename then buildPrompt(imp, m, { key = "rename", title = Strings("Name save slot"), - text = imp._rename.text, - okLabel = Strings("Save"), + text = imp._rename.text, okLabel = Strings("Save"), commit = function() imp:_commitRename() end, cancel = function() imp._rename = nil @@ -2064,14 +1806,13 @@ local function buildModals(imp, m) end, footnote = Strings("Enter to save - Esc to cancel - empty clears"), }) - return + return true end if imp._indexPrompt then buildPrompt(imp, m, { key = "index", title = Strings("Add a mod index"), hint = Strings("Paste the index URL, or its owner/repo."), - text = imp._indexPrompt.text or "", - okLabel = Strings("Add"), + text = imp._indexPrompt.text or "", okLabel = Strings("Add"), commit = function() imp:_commitAddIndex() end, cancel = function() imp._indexPrompt = nil @@ -2080,37 +1821,57 @@ local function buildModals(imp, m) paste = function() imp:_pasteIndexUrl() end, footnote = Strings("Enter to add - Esc to cancel"), }) - return - end - if imp._modConfirm then - buildConfirmModal(imp, m) - return + return true end + if imp._modConfirm then buildConfirmModal(imp, m) return true end if imp._modReleaseNotes then local ModUpdate = require("src.mods.ModUpdate") local n = imp._modReleaseNotes local body = ModUpdate.cleanBody(n.body or "", 0) if body == "" then body = Strings("(No release notes.)") end - buildTextModal(imp, m, "v" .. tostring(n.version) .. Strings(" notes"), - body, function() imp._modReleaseNotes = nil end, "release-notes") - return + buildTextModal(imp, m, "release-notes", + "v" .. tostring(n.version) .. Strings(" notes"), body, + function() imp._modReleaseNotes = nil end) + return true end if imp._findDetails then local ModUpdate = require("src.mods.ModUpdate") local d = imp._findDetails local body = ModUpdate.cleanBody(d.body or "", 0) if body == "" then body = Strings("(No description.)") end - buildTextModal(imp, m, d.title, body, - function() imp._findDetails = nil end, "find-details") - return - end - if imp._modVersions then - buildVersionsModal(imp, m) - return + buildTextModal(imp, m, "find-details", d.title, body, + function() imp._findDetails = nil end) + return true end + if imp._modVersions then buildVersionsModal(imp, m) return true end + return false end --- ------- pad cursor overlay (drawn after FlexLove, plain love.graphics) +-- --------------------------------------------------------------- overlays + +-- The blocking loader. imp.workState drives the ROM import (which reports +-- real progress); imp._busy drives every async network operation. +local function loaderSpec(imp) + if imp.workState == "working" then + return { + title = imp.status or Strings("Working"), + detail = imp.detail, + progress = imp.progress, + } + end + local b = imp._busy + if b then + return { title = b.title, detail = b.detail, progress = b.progress, + onCancel = b.cancel } + end + -- The boot prewarm runs without an overlay (the user did not ask for it and + -- must be able to use the launcher meanwhile), but if they reach the Find + -- Mods tab before it lands, THEN they are waiting on it and it earns one. + if imp.tab == "find" and imp._findFetch and not imp.findLoaded then + return { title = Strings("Loading mod index") } + end + return nil +end local function drawPadCursor(imp) if not imp._padCursorActive then return end @@ -2131,105 +1892,64 @@ local function drawPadCursor(imp) love.graphics.polygon("fill", x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) - love.graphics.setColor(0.05, 0.07, 0.12, 1) + love.graphics.setColor(0, 0, 0, 1) love.graphics.polygon("line", x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) love.graphics.pop() end --- ------- frame assembly +-- ------------------------------------------------------------ frame assembly function LauncherView.draw(imp) - ensureFlex(imp) + ensureState(imp) + local m = Layout.metrics(1200) - local W, H = love.graphics.getDimensions() - if imp._lastW ~= W or imp._lastH ~= H then - imp._lastW, imp._lastH = W, H - pcall(FlexLove.resize) - -- Persisted immediate-mode state (scroll offsets and the scroll - -- manager's cached geometry) survives a resize keyed by element id, so - -- the old window's scissors and scrollbar metrics kept clipping the new - -- layout. Drop it all; losing the scroll position on a resize is the - -- lesser cost. - pcall(FlexLove.clearAllStates) + -- The pointer is the pad cursor while it is active, so the ring, hover and + -- clicks all agree on where "the pointer" is. + local mx, my = 0, 0 + if imp._padCursorActive then + mx, my = imp._padCursor.x, imp._padCursor.y + elseif love.mouse and love.mouse.getPosition then + mx, my = love.mouse.getPosition() end + local click = imp._clickPt + if click then mx, my = click.x, click.y end - -- flat backdrop, painted before the element tree renders over it - love.graphics.setColor(C("bg"):toRGBA()) - love.graphics.rectangle("fill", 0, 0, W, H) - love.graphics.setColor(1, 1, 1, 1) + Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) + imp._clickPt = nil + imp._wheelY = 0 - local ox, oy, sw, sh = SafeArea.rect() - local s = clamp(sh / 768, 0.62, 1.5) - local appW = math.min(sw, 1200 * s) - local m = { - W = W, H = H, s = s, - x = ox + (sw - appW) / 2, top = oy, - w = appW, h = sh, - pad = clamp(appW * 0.03, 10, 24), - chip = math.max(34, 42 * s), - logoH = clamp(sh * 0.11, 40, 96), - btnH = math.max(34, 40 * s), - cardPad = { horizontal = 16, vertical = 14 }, - twoCol = appW >= 640, - } - m.colGap = 16 * m.s - -- scrollbars are hidden (wheel and touch drag still scroll); the slim - -- gutter is breathing room so content never touches the window edge - m.gutter = 8 - m.contentW = appW - 2 * m.pad - m.gutter - m.colW = m.twoCol and math.floor((m.contentW - m.colGap) / 2) or m.contentW + Theme.field() - local root = mk({ - x = m.x, y = m.top, width = m.w, height = m.h, - positioning = "flex", flexDirection = "vertical", - }) - buildHeader(imp, root, m) + local contentY = buildHeader(imp, m) + local footH = footerHeight(imp, m) + local footY = m.top + m.h - footH + local availH = footY - contentY - m.gap - -- One scroll region per tab (stable id keeps its offset across frames and - -- separate per tab), holding the panel, the updater banner and the footer. - -- flexShrink/minHeight keep this viewport-sized so overflowY can scroll. - local page = mk({ - parent = root, id = "page-" .. imp.tab, - width = "100%", flex = 1, flexShrink = 1, minHeight = 0, - overflowY = "scroll", hideScrollbars = true, - positioning = "flex", flexDirection = "vertical", - gap = 12 * m.s, - padding = { left = m.pad, right = m.pad + m.gutter, - top = 14 * m.s, bottom = 10 * m.s }, - }) + local x, w = m.contentX, m.contentW if imp.tab == "mods" then - buildModsPanel(imp, page, m) + buildModsPanel(imp, x, contentY, w, availH, m) elseif imp.tab == "find" then - buildFindPanel(imp, page, m) + buildFindPanel(imp, x, contentY, w, availH, m) else - buildGamePanel(imp, page, m, imp.tab) + buildGamePanel(imp, x, contentY, w, availH, m, imp.tab) end - buildBanner(imp, page, m) - buildFooter(imp, page, m) + buildFooter(imp, m, footY) buildModals(imp, m) - FlexLove.draw() - drawPadCursor(imp) - - -- Dev harness: POKEPORT_LAUNCHER_DUMP=1 prints the laid-out tree once - -- (id/text, x, y, w, h) so geometry bugs are read off numbers instead of - -- guessed from screenshots. - if os.getenv("POKEPORT_LAUNCHER_DUMP") == "1" and not imp._dumped - and imp._shotTimer and imp._shotTimer > 1.0 then - imp._dumped = true - local function walk(el, depth) - local tag = el.id or (el.text and ("%q"):format( - tostring(el.text):sub(1, 24))) or "-" - print(("%s%s x=%.0f y=%.0f w=%.0f h=%.0f"):format( - (" "):rep(depth), tag, el.x or -1, el.y or -1, - el.width or -1, el.height or -1)) - for _, ch in ipairs(el.children or {}) do walk(ch, depth + 1) end + -- The loader sits above everything, including modals: it is the one thing + -- that must never be clicked around. + local spec = loaderSpec(imp) + if spec then + if Loader.overlay(m, spec) and spec.onCancel then + queueAction(imp, "loader-cancel", spec.onCancel) end - for _, el in ipairs(FlexLove.topElements or {}) do walk(el, 0) end end + + Kit.endFrame() + drawPadCursor(imp) end return LauncherView diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5409a371..33d97b7d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1049,7 +1049,13 @@ function RomImporter.new(onComplete, opts) -- for click hit-testing inside EventHandler. touchPollable = mobileFileBridge and love.touch ~= nil and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil, - tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" + -- Active launcher tab: "red"/"blue"/"yellow"/"mods"/"find". A --game + -- shortcut for a version that is not importable yet lands here, so the + -- player at least arrives on the tab they asked for (src/core/LaunchOptions). + tab = (function() + local okLO, LO = pcall(require, "src.core.LaunchOptions") + return (okLO and LO.pendingTab) or "red" + end)(), logo = love.graphics.newImage("assets/logo/logo.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"), ready = {}, returning = {}, romName = {}, @@ -1159,9 +1165,9 @@ function RomImporter.new(onComplete, opts) end -- Self-updater: the interactive launcher on a real fused build kicks off one - -- async release check as it comes up; draw() polls Check.state() to render an - -- unobtrusive banner beneath the columns. Held behind pcall so a broken or - -- absent updater can never take the launcher down with it. + -- async release check as it comes up; the top-right update control polls + -- Check.state() and glows when there is something to do. Held behind pcall + -- so a broken or absent updater can never take the launcher down with it. if self.launcher and updaterAllowed() then local ok, Check = pcall(require, "src.update.Check") if ok and Check then @@ -1170,6 +1176,26 @@ function RomImporter.new(onComplete, opts) end end + -- PREWARM. Start the mod-index fetch at boot rather than when the Find + -- Mods tab is first opened. The work is identical either way, but doing it + -- now means it overlaps the time the user spends looking at the game tab, + -- so the tab is already populated when they reach it instead of greeting + -- them with a loader. Nothing here blocks: the fetch pool is off-thread + -- and _pumpFindFetch collects the result whenever it lands. + -- + -- Deliberately NOT behind the blocking overlay: the user did not ask for + -- this and must be able to use the launcher while it runs, so _busy is + -- cleared straight back out. An explicit Refresh press still shows one. + if self.launcher then + pcall(function() + self:_refreshFindSources() + if #(self.findSources or {}) > 0 then + self:_refreshFind(false) + self:_clearBusy() + end + end) + end + -- On Linux handhelds / NX a gamepad is usually already connected at boot; -- arm the virtual cursor immediately so the player does not have to press a -- button before seeing something move. Desktop keeps the cursor latent @@ -1815,6 +1841,17 @@ function RomImporter:update(dt) if self._flex then require("src.import.LauncherView").update(self, dt) end + -- Drive every in-flight async fetch. These are the operations that used to + -- run synchronously inside draw and freeze the window; each pump is a + -- non-blocking channel poll, so a frame with nothing in flight costs + -- nothing. They run whether or not the view is up, so a refresh started + -- before a tab switch still completes. + self:_pumpFindFetch() + self:_pumpModInfoFetch() + self:_pumpFindStats() + self:_pumpFindThumbs() + self:_pumpModCheck() + self:_pumpModInstall() -- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from -- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and -- quits, so a scripted run can see the real launcher at any window shape @@ -2235,8 +2272,28 @@ end -- it rebuilds the element tree from this importer's state every frame and -- renders it. Required lazily so a headless test require of this module -- never loads the UI toolkit. +-- Dev harness: POKEPORT_LAUNCHER_PROF= times the view's build+draw +-- for that many frames, prints mean/median/p95/worst to stdout and quits. +-- Pair with POKEPORT_LAUNCHER_TAB / POKEPORT_WIN to profile a specific panel. +local profN, profSamples = tonumber(os.getenv("POKEPORT_LAUNCHER_PROF") or ""), {} + function RomImporter:draw() - require("src.import.LauncherView").draw(self) + local View = require("src.import.LauncherView") + if not profN then return View.draw(self) end + local t0 = love.timer.getTime() + View.draw(self) + profSamples[#profSamples + 1] = (love.timer.getTime() - t0) * 1000 + if #profSamples >= profN + 30 then + local s = {} + for i = 31, #profSamples do s[#s + 1] = profSamples[i] end -- drop warmup + table.sort(s) + local sum = 0 + for _, v in ipairs(s) do sum = sum + v end + io.stderr:write(("PROF frames=%d mean=%.2fms median=%.2fms p95=%.2fms worst=%.2fms\n") + :format(#s, sum / #s, s[math.ceil(#s * 0.5)], s[math.ceil(#s * 0.95)], s[#s])) + io.stderr:flush() + love.event.quit() + end end -- Nothing in the launcher can undo a delete, so every Delete control asks @@ -2430,6 +2487,12 @@ function RomImporter:keypressed(key) return end if self.workState == "working" then return end + -- Keyboard focus ring: arrows move it, Enter activates it -- but only once + -- the arrows have been used, so the long-standing "Enter plays the visible + -- game" shortcut below still works for anyone who never touches the ring. + if self._flex and require("src.import.LauncherView").keypressed(self, key) then + return + end if key == "return" or key == "space" or key == "kpenter" then -- Enter acts on the visible game tab: Play if its ROM is ready, otherwise -- open its picker. The mods tab has no keyboard action. @@ -2614,57 +2677,88 @@ end -- Resolve cached (or freshly fetched) GitHub status for every mod that -- declares a github field. force=true bypasses the 6h cache on every repo. -- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. +-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any +-- toggle or install -- and used to make one blocking curl call per mod with a +-- github field, in a loop, on the render thread. A handful of mods was a +-- multi-second freeze of the whole launcher. Now each mod gets a handle and +-- they resolve together across later frames; a mod whose cache is still fresh +-- resolves on the first pump with no network at all. function RomImporter:_syncModUpdateInfo(force) local ModUpdate = require("src.mods.ModUpdate") self.modUpdateInfo = self.modUpdateInfo or {} + local pending = {} for _, m in ipairs(self.mods or {}) do if m.github and m.github ~= "" then - local ok, packed = pcall(function() - local releases, err, meta = ModUpdate.fetchReleases(m.github, m.id, { - force = force == true, - }) - local cached = ModUpdate.readCache(m.github) - return { - releases = releases, - err = err, - meta = meta, - checkedAt = (cached and cached.checkedAt) or os.time(), - } - end) - if not ok then - self.modUpdateInfo[m.id] = { - status = "error", err = tostring(packed), - } - elseif packed.releases then - local status, best = ModUpdate.statusFor(m.version, packed.releases) - self.modUpdateInfo[m.id] = { - status = status, - latest = best and best.version or nil, - best = best, - releases = packed.releases, - downloads = ModUpdate.totalDownloads(packed.releases), - dates = ModUpdate.releaseDates(packed.releases), - err = nil, - checkedAt = packed.checkedAt or os.time(), - } - else - self.modUpdateInfo[m.id] = { - status = "error", - latest = nil, - best = nil, - releases = nil, - err = tostring(packed.err), - } - end + pending[#pending + 1] = { mod = m, + h = ModUpdate.beginFetchReleases(m.github, m.id, { force = force == true }) } else self.modUpdateInfo[m.id] = nil end end - -- Bump so the view's sorted-list cache (keyed on this revision) rebuilds - -- when release/download data actually changes, not every frame. + self._modInfoFetch = (#pending > 0) and pending or nil + -- Bump immediately so a mod that lost its github field (or a list that + -- shrank) is reflected without waiting on the network. self._modUpdateRev = (self._modUpdateRev or 0) + 1 end +-- Drive in-flight release checks one frame at a time. Called from update(). +-- Deliberately NOT behind the blocking overlay: this is background enrichment +-- of rows that are already usable, so the list stays interactive while the +-- download counts and update badges fill in. Individual rows show their own +-- inline spinner instead. +function RomImporter:_pumpModInfoFetch() + local pending = self._modInfoFetch + if not pending then return end + local ModUpdate = require("src.mods.ModUpdate") + local remaining, changed = {}, false + for _, item in ipairs(pending) do + local m = item.mod + local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, item.h) + if not ok then + self.modUpdateInfo[m.id] = { status = "error", err = tostring(done) } + changed = true + elseif done then + changed = true + if releases then + local status, best = ModUpdate.statusFor(m.version, releases) + local cached = ModUpdate.readCache(m.github) + self.modUpdateInfo[m.id] = { + status = status, + latest = best and best.version or nil, + best = best, + releases = releases, + downloads = ModUpdate.totalDownloads(releases), + dates = ModUpdate.releaseDates(releases), + err = nil, + checkedAt = (cached and cached.checkedAt) or os.time(), + } + else + self.modUpdateInfo[m.id] = { + status = "error", latest = nil, best = nil, releases = nil, + err = tostring(err), + } + end + else + remaining[#remaining + 1] = item + end + end + self._modInfoFetch = (#remaining > 0) and remaining or nil + if changed then + -- Bump so the view's sorted-list cache (keyed on this revision) rebuilds + -- when release/download data actually changes, not every frame. + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + end +end + +-- True while any mod's release check is still in flight, so a row can show +-- an inline spinner instead of "Not checked for updates yet". +function RomImporter:_modInfoPending(id) + for _, item in ipairs(self._modInfoFetch or {}) do + if item.mod.id == id then return true end + end + return false +end + function RomImporter:_modUpdateInfo(id) return self.modUpdateInfo and self.modUpdateInfo[id] or nil end @@ -2753,41 +2847,19 @@ function RomImporter:_modGithubAction(id, action) text = "Remote mod download is unavailable on this platform." } return end - local ran, err = pcall(function() - local ModUpdate = require("src.mods.ModUpdate") - local row - for _, m in ipairs(self.mods or {}) do - if m.id == id then row = m; break end - end - if not row or not row.github then - self.modNotice = { ok = false, text = "This mod has no github field" } - return - end + local ModUpdate = require("src.mods.ModUpdate") + local row + for _, m in ipairs(self.mods or {}) do + if m.id == id then row = m; break end + end + if not row or not row.github then + self.modNotice = { ok = false, text = "This mod has no github field" } + return + end - if action == "versions" then - self.modNotice = { ok = true, text = "Loading versions..." } - local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {}) - if not releases then - self.modNotice = { ok = false, text = tostring(fetchErr) } - return - end - local status, best = ModUpdate.statusFor(row.version, releases) - self.modUpdateInfo = self.modUpdateInfo or {} - self.modUpdateInfo[row.id] = { - status = status, latest = best and best.version, best = best, - releases = releases, - downloads = ModUpdate.totalDownloads(releases), - dates = ModUpdate.releaseDates(releases), - } - self._modVersions = { - id = row.id, name = row.name, current = row.version, - releases = releases, scroll = 0, - } - self.modNotice = nil - return - end - - -- update / check + -- Update, when we already know a newer release exists, needs no network: + -- confirm straight away off the cached info. + if action ~= "versions" then local info = self:_modUpdateInfo(id) if info and info.status == "available" and info.best then self._modConfirm = { @@ -2802,51 +2874,177 @@ function RomImporter:_modGithubAction(id, action) } return end + end - -- Manual check (or first click when status is current/unknown/error) - self.modNotice = { ok = true, text = "Checking " .. row.github .. "..." } - local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, { - force = true, - }) - if not releases then - self.modNotice = { ok = false, text = tostring(fetchErr) } - return - end - if #releases == 0 then - self.modNotice = { ok = false, text = "No .zip releases found" } - return - end - local status, best = ModUpdate.statusFor(row.version, releases) - self.modUpdateInfo = self.modUpdateInfo or {} - self.modUpdateInfo[row.id] = { - status = status, latest = best and best.version, best = best, - releases = releases, checkedAt = os.time(), - downloads = ModUpdate.totalDownloads(releases), - dates = ModUpdate.releaseDates(releases), - } - if status == "available" and best then - self.modNotice = { ok = true, - text = row.name .. ": new version available (v" .. best.version .. ")" } - self._modConfirm = { - kind = "update", id = row.id, release = best, - title = "Update available", - yesLabel = "Update", - lines = { - "Update " .. row.name .. "?", - "Installed v" .. tostring(row.version), - "Latest v" .. tostring(best.version), - }, - } - else - self.modNotice = { ok = true, - text = row.name .. " is up to date (v" - .. tostring(row.version) .. ")" } - end - end) - if not ran then + -- ASYNC (was a blocking fetch). Both remaining paths -- listing versions + -- and a manual re-check -- hit the GitHub API, which is exactly the call + -- that used to freeze the launcher mid-click. One job at a time. + if self._modCheck then return end + self._modCheck = { + id = row.id, name = row.name, github = row.github, + version = row.version, action = action, + h = ModUpdate.beginFetchReleases(row.github, row.id, + { force = action ~= "versions" }), + } + self:_setBusy(action == "versions" and Strings("Loading versions") + or Strings("Checking for updates"), row.name) +end + +-- Drive the in-flight per-mod release check. Called from _pumpModInfoFetch's +-- neighbourhood in update(); kept separate because this one IS behind the +-- blocking overlay (the user pressed a button and is waiting on the answer). +function RomImporter:_pumpModCheck() + local job = self._modCheck + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, job.h) + if ok and not done then return end + self._modCheck = nil + self:_clearBusy() + if not ok then self._modVersions = nil - self.modNotice = { ok = false, - text = "Update failed: " .. tostring(err) } + self.modNotice = { ok = false, text = "Update failed: " .. tostring(done) } + return + end + if not releases then + self.modNotice = { ok = false, text = tostring(err) } + return + end + if #releases == 0 then + self.modNotice = { ok = false, text = "No .zip releases found" } + return + end + + local status, best = ModUpdate.statusFor(job.version, releases) + self.modUpdateInfo = self.modUpdateInfo or {} + self.modUpdateInfo[job.id] = { + status = status, latest = best and best.version, best = best, + releases = releases, checkedAt = os.time(), + downloads = ModUpdate.totalDownloads(releases), + dates = ModUpdate.releaseDates(releases), + } + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + + if job.action == "versions" then + self._modVersions = { + id = job.id, name = job.name, current = job.version, + releases = releases, page = 1, + } + self.modNotice = nil + return + end + + if status == "available" and best then + self.modNotice = { ok = true, + text = job.name .. ": new version available (v" .. best.version .. ")" } + self._modConfirm = { + kind = "update", id = job.id, release = best, + title = "Update available", + yesLabel = "Update", + lines = { + "Update " .. job.name .. "?", + "Installed v" .. tostring(job.version), + "Latest v" .. tostring(best.version), + }, + } + else + self.modNotice = { ok = true, + text = job.name .. " is up to date (v" .. tostring(job.version) .. ")" } + end +end + +-- ------- mod install / update (async download, blocking unzip) +-- +-- The download is the slow half and now runs on the fetch pool behind a +-- non-dismissable loader; unzipping the finished archive is fast and stays +-- on the main thread, where love.filesystem belongs. All three entry points +-- (update a mod, install a specific version, install from an index) funnel +-- into one in-flight job, so two installs can never race for the same id. +-- +-- `spec` = { modId, name, release, notice = "mod"|"find", verb, entry } +function RomImporter:_beginModInstall(spec) + if self._modInstall then return end + local ModIndex = require("src.mods.ModIndex") + local release = spec.release + -- An index entry only tells us WHERE the zip is; resolving that is + -- ModIndex's job, exactly as in the synchronous path. + if not release and spec.entry then + local resolved, why = ModIndex.releaseFor(spec.entry) + if not resolved then + self:_modInstallFailed(spec, why or "this mod cannot be installed") + return + end + release = resolved + end + if type(release) ~= "table" or not release.zip or not release.zip.url then + self:_modInstallFailed(spec, "release has no downloadable .zip") + return + end + local version = release.version or os.time() + local tmpName = ("mod_update_%s_%s.zip"):format(tostring(spec.modId), + tostring(version)) + local ModUpdate = require("src.mods.ModUpdate") + self._modInstall = { + spec = spec, release = release, version = release.version, + h = ModUpdate.beginDownloadZip(release.zip.url, tmpName, + release.zip.size), + } + self:_setBusy(Strings("Downloading %s", tostring(spec.name or spec.modId)), + "v" .. tostring(release.version or "?")) +end + +function RomImporter:_modInstallFailed(spec, msg) + local notice = { ok = false, text = tostring(msg) } + if spec.notice == "find" then self.findNotice = notice + else self.modNotice = notice end + self:_clearBusy() +end + +function RomImporter:_pumpModInstall() + local job = self._modInstall + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + local ok, done, path, err, progress = pcall(ModUpdate.pumpDownloadZip, job.h) + if ok and not done then + -- Feed real download progress into the overlay when the size is known. + if progress and self._busy then self._busy.progress = progress end + return + end + self._modInstall = nil + local spec = job.spec + if not ok then + self:_modInstallFailed(spec, "download failed: " .. tostring(done)) + return + end + if not path then + self:_modInstallFailed(spec, err or "download failed") + return + end + -- Unzip + manifest check. Fast, and it must run here: love.filesystem + -- writes are main-thread only. + self:_setBusy(Strings("Installing %s", tostring(spec.name or spec.modId))) + local LauncherMods = require("src.mods.LauncherMods") + local ran, res, resErr = pcall(LauncherMods.installDownloadedZip, + spec.modId, path, job.version) + self:_clearBusy() + if not ran then + self:_modInstallFailed(spec, "install failed: " .. tostring(res)) + return + end + if not res then + self:_modInstallFailed(spec, resErr or "install failed") + return + end + -- The installed list is what the Install / Installed labels read, so it has + -- to be re-derived before the next paint or the card lies. + pcall(self._refreshMods, self) + local shown = tostring(resErr or job.version or "") + local text = ("%s %s %s"):format(spec.verb or "Installed", + tostring(spec.name or spec.modId), shown) + if spec.notice == "find" then + self.findNotice = { ok = true, text = text } + else + self.modNotice = { ok = true, text = text } end end @@ -2855,45 +3053,19 @@ function RomImporter:_confirmModUpdate(modId, release) for _, m in ipairs(self.mods or {}) do if m.id == modId then row = m; break end end - local name = row and row.name or modId - self.modNotice = { ok = true, - text = "Downloading " .. tostring(release and release.version or "?") .. "..." } - local ran, err = pcall(function() - local LauncherMods = require("src.mods.LauncherMods") - local ok, res = LauncherMods.installFromRelease(modId, release) - if ok then - pcall(self._refreshMods, self) - self.modNotice = { ok = true, - text = "Updated " .. name .. " to " .. tostring(res) } - else - self.modNotice = { ok = false, text = tostring(res) } - end - end) - if not ran then - self.modNotice = { ok = false, text = "Update failed: " .. tostring(err) } - end + self:_beginModInstall({ + modId = modId, name = row and row.name or modId, + release = release, verb = "Updated", notice = "mod", + }) end function RomImporter:_installModVersion(modId, release) self._modVersions = nil self._modReleaseNotes = nil - local version = release and release.version or "?" - self.modNotice = { ok = true, text = "Downloading " .. tostring(version) .. "..." } - local ran, err = pcall(function() - local LauncherMods = require("src.mods.LauncherMods") - local ok, res = LauncherMods.installFromRelease(modId, release) - if ok then - pcall(self._refreshMods, self) - self.modNotice = { ok = true, - text = "Installed " .. tostring(modId) .. " " .. tostring(res) } - else - self.modNotice = { ok = false, text = tostring(res) } - end - end) - if not ran then - self.modNotice = { ok = false, - text = "Install failed: " .. tostring(err) } - end + self:_beginModInstall({ + modId = modId, name = modId, release = release, + verb = "Installed", notice = "mod", + }) end @@ -2969,6 +3141,12 @@ end -- there is one "nuzlocke" as far as the installer is concerned, so the panel -- must not offer two. Per-source failures are collected rather than fatal: an -- index that is down should cost its own rows, not everybody else's. +-- ASYNC (was synchronous). Every source used to be fetched with a blocking +-- curl call inside the draw path, so opening Find Mods froze the window for +-- as long as the slowest index took -- measured at over two minutes on a +-- cold open, with no spinner, because the frame that would have drawn one +-- never ran. The fetch now starts here and completes across later frames in +-- _pumpFindFetch; the loader overlay is up for the whole flight. function RomImporter:_refreshFind(force) if not Platform.networkValidated() then self.findLoaded = true @@ -2977,48 +3155,145 @@ function RomImporter:_refreshFind(force) end local ModIndex = require("src.mods.ModIndex") self:_refreshFindSources() - local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {} - local stale, oldest = false, nil - for _, source in ipairs(self.findSources or {}) do - local ok, index, err, meta = pcall(function() - return ModIndex.fetch(source, { force = force == true }) - end) - if not ok then - errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(index) - elseif not index then - errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(err) - else - if meta and meta.stale then stale = true end - if meta and meta.checkedAt then - oldest = math.min(oldest or meta.checkedAt, meta.checkedAt) - end - for _, entry in ipairs(index.mods or {}) do - if not seen[entry.id] then - seen[entry.id] = true - entry._source = source.label or source.feed - entry._base = source.base - mods[#mods + 1] = entry + local sources = self.findSources or {} + if #sources == 0 then + self.findIndex = { mods = {}, categories = {} } + self.findLoaded = true + return + end + -- One in-flight refresh at a time: a second Refresh press while the first + -- is running would double-count every row into the merge. + if self._findFetch then return end + local handles = {} + for i, source in ipairs(sources) do + handles[i] = { source = source, + h = ModIndex.beginFetch(source, { force = force == true }) } + end + self._findFetch = { + handles = handles, force = force == true, + mods = {}, seen = {}, cats = {}, catSeen = {}, errs = {}, + stale = false, oldest = nil, at = 1, + } + self:_setBusy(Strings("Fetching mod index"), + #sources == 1 and (sources[1].label or sources[1].feed) + or Strings("%d indexes", #sources)) +end + +-- Drive the in-flight index fetch one frame at a time. Called from update(). +function RomImporter:_pumpFindFetch() + local f = self._findFetch + if not f then return end + local ModIndex = require("src.mods.ModIndex") + + -- Pump every handle each frame; they run concurrently on the fetch pool. + local allDone = true + for _, item in ipairs(f.handles) do + if not item.done then + local ok, done, index, err, meta = pcall(ModIndex.pumpFetch, item.h) + if not ok then + item.done = true + f.errs[#f.errs + 1] = (item.source.label or item.source.feed) + .. ": " .. tostring(done) + elseif done then + item.done = true + if not index then + f.errs[#f.errs + 1] = (item.source.label or item.source.feed) + .. ": " .. tostring(err) + else + item.index, item.meta = index, meta end - end - for _, c in ipairs(ModIndex.categoriesIn(index)) do - if not catSeen[c] then catSeen[c] = true; cats[#cats + 1] = c end + else + allDone = false end end end - self.findIndex = { mods = mods, categories = cats, stale = stale, - checkedAt = oldest } + self._busyCount = nil + if not allDone then return end + + -- Merge in SOURCE ORDER, not completion order: first source wins on a + -- duplicate id, matching how the mod loader resolves two mods with one id, + -- and that rule has to be stable regardless of which index answered first. + for _, item in ipairs(f.handles) do + local index, meta, source = item.index, item.meta, item.source + if index then + if meta and meta.stale then f.stale = true end + if meta and meta.checkedAt then + f.oldest = math.min(f.oldest or meta.checkedAt, meta.checkedAt) + end + for _, entry in ipairs(index.mods or {}) do + if not f.seen[entry.id] then + f.seen[entry.id] = true + entry._source = source.label or source.feed + entry._base = source.base + f.mods[#f.mods + 1] = entry + end + end + for _, c in ipairs(ModIndex.categoriesIn(index)) do + if not f.catSeen[c] then + f.catSeen[c] = true + f.cats[#f.cats + 1] = c + end + end + end + end + + self.findIndex = { mods = f.mods, categories = f.cats, stale = f.stale, + checkedAt = f.oldest } self.findLoaded = true - if #errs > 0 then - self.findNotice = { ok = false, text = table.concat(errs, " - ") } - elseif force then + if #f.errs > 0 then + self.findNotice = { ok = false, text = table.concat(f.errs, " - ") } + elseif f.force then self.findNotice = { ok = true, - text = Strings("Refreshed - %d mods listed", #mods) } + text = Strings("Refreshed - %d mods listed", #f.mods) } end -- A category that no longer exists after a refresh would filter everything -- away with no way back except guessing. - if self.findCategory and not catSeen[self.findCategory] then + if self.findCategory and not f.catSeen[self.findCategory] then self.findCategory = nil end + self.findPage = 1 + self._findFetch = nil + self:_clearBusy() +end + +-- Clear every input rebind and the dragged touch-overlay layout, restoring +-- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE +-- (src/core/Input.lua:applyBindings layers options.bindings over the +-- defaults instead of replacing them), so a player who has bound themselves +-- into a corner has no in-game way out; this is it. The running game reads +-- bindings on its next start, which is the same contract every other +-- launcher setting has. +function RomImporter:_resetRebinds() + local ok = pcall(function() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.bindings = nil + if type(opts.touchControls) == "table" then + opts.touchControls.layouts = nil + end + SaveData.saveOptions(opts) + end) + -- Its own notice slot: this button lives on the game panel, and borrowing + -- the mods or save notice would print the result on a tab the user is not + -- looking at. + if ok then + self.controlsNotice = { ok = true, + text = Strings("Controls reset to defaults. Applies on the next start.") } + else + self.controlsNotice = { ok = false, + text = Strings("Could not reset controls.") } + end +end + +-- ------- busy state (drives the non-dismissable loader overlay) +-- Anything that makes the user wait sets this; LauncherView renders it as a +-- blocking overlay so no operation can ever run invisibly. +function RomImporter:_setBusy(title, detail, cancel) + self._busy = { title = title, detail = detail, cancel = cancel } +end + +function RomImporter:_clearBusy() + self._busy = nil end function RomImporter:_ensureFind() @@ -3070,21 +3345,50 @@ function RomImporter:_findThumb(entry) self._findThumbs = self._findThumbs or {} local cached = self._findThumbs[entry.id] if cached ~= nil then return cached or nil end - if self._findThumbFetched then return nil end -- budget spent this frame local ModIndex = require("src.mods.ModIndex") local url = ModIndex.joinUrl(entry._base, entry.thumbnail) if not url then self._findThumbs[entry.id] = false return nil end - self._findThumbFetched = true - local ok, image = pcall(function() - local path, err = ModIndex.downloadThumbnail(url, entry.id) - if not path then error(err or "download failed", 0) end - return love.graphics.newImage(path) - end) - self._findThumbs[entry.id] = ok and image or false - return ok and image or nil + -- ASYNC (was one blocking download per frame). Only rows on the current + -- page ever ask, so pagination already bounds this to a page's worth of + -- requests; the fetch pool runs them off-thread and the card shows its + -- placeholder until the image lands. + self._findThumbFetch = self._findThumbFetch or {} + if not self._findThumbFetch[entry.id] then + local ext = url:match("%.(%a%a%a?%a?)$") or "png" + local name = ("mod_thumb_%s.%s") + :format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext) + local Fetch = require("src.net.Fetch") + self._findThumbFetch[entry.id] = { + job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }), + } + end + return nil +end + +-- Turn finished thumbnail downloads into images. Called from update(), so +-- love.graphics.newImage runs on the render thread where it belongs. +function RomImporter:_pumpFindThumbs() + local pending = self._findThumbFetch + if not pending then return end + local Fetch = require("src.net.Fetch") + for id, item in pairs(pending) do + local st = Fetch.poll(item.job) + if st.status ~= "pending" then + Fetch.release(item.job) + pending[id] = nil + local image + if st.status == "ok" and st.path then + local ok, img = pcall(love.graphics.newImage, st.path) + image = ok and img or nil + end + self._findThumbs = self._findThumbs or {} + self._findThumbs[id] = image or false + end + end + if next(pending) == nil then self._findThumbFetch = nil end end -- Release stats for a FIND MODS row, resolved the same way the MODS tab @@ -3110,31 +3414,54 @@ function RomImporter:_findStats(entry) self._findStatsCache[entry.id] = cached return cached end - if self._findStatsFetched then return nil end -- budget spent this frame if not entry.github or entry.github == "" then cached = { done = true } self._findStatsCache[entry.id] = cached return cached end - self._findStatsFetched = true - local ModUpdate = require("src.mods.ModUpdate") - local list, fetchErr - local ok = pcall(function() - list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {}) - end) - local stats = list and ModUpdate.statsForReleases(list) or nil - if stats then - cached = { total = stats.total, first = stats.first, - latest = stats.latest, done = true } - else - -- A repo that does not exist is permanent; every other failure (the - -- hourly API rate limit, a hiccup) is retried in a minute so rows can - -- recover without restarting the launcher. - local permanent = tostring(fetchErr):find("Not Found", 1, true) ~= nil - cached = { done = permanent, retryAt = os.time() + 60 } + -- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded + -- how many stalls happened at once, not how long each one lasted: every + -- frame that started a fetch blocked for the whole round trip, so scrolling + -- a listing juddered once per row. Rows now queue a handle and fill in + -- when it lands; until then the row simply has no stats line. + self._findStatsPending = self._findStatsPending or {} + if not self._findStatsPending[entry.id] then + local ModUpdate = require("src.mods.ModUpdate") + self._findStatsPending[entry.id] = { + id = entry.id, + h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}), + } end - self._findStatsCache[entry.id] = cached - return cached + return nil +end + +-- Drive in-flight FIND MODS stats lookups. Called from update(). +function RomImporter:_pumpFindStats() + local pending = self._findStatsPending + if not pending then return end + local ModUpdate = require("src.mods.ModUpdate") + for id, item in pairs(pending) do + local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, item.h) + if not ok or done then + pending[id] = nil + local stats = (ok and releases) and ModUpdate.statsForReleases(releases) or nil + local cached + if stats then + cached = { total = stats.total, first = stats.first, + latest = stats.latest, done = true } + else + -- A repo that does not exist is permanent; every other failure (the + -- hourly API rate limit, a hiccup) is retried in a minute so rows can + -- recover without restarting the launcher. + local permanent = tostring(ok and err or done) + :find("Not Found", 1, true) ~= nil + cached = { done = permanent, retryAt = os.time() + 60 } + end + self._findStatsCache = self._findStatsCache or {} + self._findStatsCache[id] = cached + end + end + if next(pending) == nil then self._findStatsPending = nil end end -- Open the "add an index" text prompt. Deliberately a typed URL rather than a @@ -3231,24 +3558,10 @@ function RomImporter:_findConfirmInstall(entry) end function RomImporter:_findInstall(entry) - local name = entry.title or entry.id - self.findNotice = { ok = true, text = Strings("Downloading %s...", name) } - local ran, err = pcall(function() - local LauncherMods = require("src.mods.LauncherMods") - local ok, res = LauncherMods.installFromIndex(entry) - if ok then - -- The installed list is what the Install / Installed labels read, so it - -- has to be re-derived before the next paint or the card lies. - pcall(self._refreshMods, self) - self.findNotice = { ok = true, - text = Strings("Installed %s %s", name, tostring(res)) } - else - self.findNotice = { ok = false, text = tostring(res) } - end - end) - if not ran then - self.findNotice = { ok = false, text = "Install failed: " .. tostring(err) } - end + self:_beginModInstall({ + modId = entry.id, name = entry.title or entry.id, entry = entry, + verb = "Installed", notice = "find", + }) end return RomImporter diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index bc6b7626..b2834028 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -726,6 +726,30 @@ function LauncherMods.installFromRelease(modId, release) return result, err end +-- The install half of installFromRelease, split out so the launcher can run +-- the DOWNLOAD half asynchronously (src/net/Fetch.lua) and still land in the +-- same place. `localPath` is a love.filesystem-relative path to an already +-- downloaded zip; it is consumed (removed) either way. +-- Returns true, version | nil, errString. +function LauncherMods.installDownloadedZip(modId, localPath, version) + local ok, result, err = pcall(function() + if type(modId) ~= "string" or modId == "" then + return nil, "missing mod id" + end + if type(localPath) ~= "string" or localPath == "" then + return nil, "missing downloaded archive" + end + local installed, res = LauncherMods.installZip(localPath, { + replace = true, expectId = modId, + }) + pcall(love.filesystem.remove, localPath) + if not installed then return nil, res end + return true, version or res + end) + if not ok then return nil, "install failed: " .. tostring(result) end + return result, err +end + -- Install a mod listed in a community index (src/mods/ModIndex.lua). -- The index only ever tells us WHERE the zip is; resolving that URL is -- ModIndex's job and installing it is installFromRelease's, so this is the diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 9bb71542..c1e145fa 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -592,6 +592,101 @@ function ModIndex.fetch(source, opts) return index, nil, { fromCache = false, checkedAt = os.time() } end +-- ------- async fetch (the launcher's path; ModIndex.fetch above stays as the +-- synchronous one for tests and non-UI callers) +-- +-- ModIndex.fetch blocks on curl, which on the render thread froze the Find +-- Mods tab for as long as the server took. These three functions are the +-- same state machine driven a frame at a time over src/net/Fetch.lua: +-- local h = ModIndex.beginFetch(source, { force = true }) +-- -- every frame: +-- local done, index, err, meta = ModIndex.pumpFetch(h) +-- pumpFetch returns done=false while the request is in flight. The cache +-- rules are identical to the sync path: a fresh cache short-circuits the +-- network entirely (so the handle completes on its first pump), a failed +-- live fetch falls back to stale cache, and the fallback mirror gets one try +-- before the feed counts as an outage. +function ModIndex.beginFetch(source, opts) + opts = opts or {} + local h = { source = source, opts = opts, stage = "start" } + if type(source) ~= "table" or type(source.feed) ~= "string" then + h.stage, h.err = "done", "missing index source" + return h + end + return h +end + +-- Shared with the sync path's `cached` closure: read whatever is in the +-- options cache and shape it like a parsed index. +local function cachedIndex(feed, stale) + local entry = ModIndex.readCache(feed) + if not entry then return nil end + return { + schemaVersion = ModIndex.SCHEMA_VERSION, + generatedAt = entry.generatedAt, + categories = entry.categories or {}, + mods = entry.mods or {}, + }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } +end + +-- Returns done, index, err, meta. +function ModIndex.pumpFetch(h) + if not h then return true, nil, "no handle" end + local Fetch = require("src.net.Fetch") + local feed = h.source and h.source.feed + + if h.stage == "done" then + return true, h.index, h.err, h.meta + end + + if h.stage == "start" then + if not h.opts.force then + local entry = ModIndex.readCache(feed) + if ModIndex.cacheFresh(entry) then + h.index, h.err, h.meta = cachedIndex(feed, false) + h.stage = "done" + return true, h.index, h.err, h.meta + end + end + h.job = Fetch.get(feed, { userAgent = "gen1recomp-mod-index" }) + h.stage = "feed" + return false + end + + local st = Fetch.poll(h.job) + if st.status == "pending" then return false end + Fetch.release(h.job) + + if st.status == "ok" and st.body then + local index, parseErr = ModIndex.parse(st.body) + if index then + ModIndex.writeCache(feed, index) + h.index, h.meta = index, { fromCache = false, checkedAt = os.time() } + h.stage = "done" + return true, h.index, nil, h.meta + end + -- A feed that parses badly is an outage as far as the UI is concerned. + h.parseErr = parseErr + end + + -- Pages deploys trail a push; the raw mirror is the same file, so a feed + -- that fails right after a release is worth one retry elsewhere. + if h.stage == "feed" and h.source.fallback then + h.job = Fetch.get(h.source.fallback, { userAgent = "gen1recomp-mod-index" }) + h.stage = "fallback" + return false + end + + local index, _, meta = cachedIndex(feed, true) + h.stage = "done" + if index then + h.index, h.meta = index, meta + return true, index, nil, meta + end + h.err = h.parseErr or st.err or "index fetch failed" + return true, nil, h.err +end + -- Fetch a description_url / any index-relative text file. Returns the raw -- markdown; callers run it through ModUpdate.cleanBody for display. function ModIndex.fetchText(url) diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index d9051663..8c71c859 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -416,6 +416,117 @@ function ModUpdate.fetchReleases(repo, modId, opts) return list, nil, { fromCache = false } end +-- ------- async siblings (the launcher's path; the sync functions above stay +-- for tests and non-UI callers) +-- +-- Same cache and fallback rules as fetchReleases, driven one frame at a time +-- over src/net/Fetch.lua so a release check never stalls the render thread. +-- local h = ModUpdate.beginFetchReleases(repo, modId, { force = true }) +-- local done, releases, err, meta = ModUpdate.pumpFetchReleases(h) +function ModUpdate.beginFetchReleases(repo, modId, opts) + opts = opts or {} + local h = { repo = repo, modId = modId, opts = opts, stage = "start" } + if type(repo) ~= "string" or repo == "" then + h.stage, h.err = "done", "missing github repo" + end + return h +end + +local function staleReleases(repo) + local cached = ModUpdate.readCache(repo) + if cached and cached.releases then + return cached.releases, nil, { fromCache = true, stale = true } + end + return nil +end + +-- Returns done, releases, err, meta. +function ModUpdate.pumpFetchReleases(h) + if not h then return true, nil, "no handle" end + if h.stage == "done" then return true, h.releases, h.err, h.meta end + local Fetch = require("src.net.Fetch") + + if h.stage == "start" then + if not h.opts.force then + local cached = ModUpdate.readCache(h.repo) + if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then + h.releases, h.meta = cached.releases, { fromCache = true } + h.stage = "done" + return true, h.releases, nil, h.meta + end + end + h.job = Fetch.get(ModUpdate.apiReleasesUrl(h.repo), { + userAgent = "gen1recomp-mod-updater", + accept = "application/vnd.github+json", + }) + h.stage = "fetching" + return false + end + + local st = Fetch.poll(h.job) + if st.status == "pending" then return false end + Fetch.release(h.job) + h.stage = "done" + + if st.status == "ok" and st.body then + local list, parseErr = ModUpdate.parseReleases(st.body, h.modId) + if list then + ModUpdate.writeCache(h.repo, list) + h.releases, h.meta = list, { fromCache = false } + return true, list, nil, h.meta + end + h.err = parseErr + return true, nil, parseErr + end + + -- Offline or a failed call: stale cache beats an empty list. + local rel, _, meta = staleReleases(h.repo) + if rel then + h.releases, h.meta = rel, meta + return true, rel, nil, meta + end + h.err = st.err or "release check failed" + return true, nil, h.err +end + +-- Async download of a mod zip into the save directory. Returns a handle; +-- pump it for done, savePath, err. +function ModUpdate.beginDownloadZip(url, destName, size) + local h = { stage = "done" } + if type(url) ~= "string" or url == "" then + h.err = "missing download url" + return h + end + if not (love and love.filesystem) then + h.err = "download needs LOVE" + return h + end + local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip") + name = tostring(name):gsub("[/\\]", "_") + local Fetch = require("src.net.Fetch") + h.name = name + h.stage = "fetching" + h.job = Fetch.download(url, name, { + size = size, userAgent = "gen1recomp-mod-updater" }) + return h +end + +function ModUpdate.pumpDownloadZip(h) + if not h then return true, nil, "no handle" end + if h.stage == "done" then return true, h.path, h.err end + local Fetch = require("src.net.Fetch") + local st = Fetch.poll(h.job) + if st.status == "pending" then return false, nil, nil, st.progress end + Fetch.release(h.job) + h.stage = "done" + if st.status == "ok" then + h.path = st.path or h.name + return true, h.path + end + h.err = st.err or "download failed" + return true, nil, h.err +end + function ModUpdate.downloadZip(url, destName) if type(url) ~= "string" or url == "" then return nil, "missing download url" diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua new file mode 100644 index 00000000..d570146e --- /dev/null +++ b/src/net/Fetch.lua @@ -0,0 +1,188 @@ +-- Async HTTP for the launcher: a small job queue over a pool of love.thread +-- workers. +-- +-- WHY THIS EXISTS. Every network call in the launcher used to run on the +-- render thread. HostShell.httpGet shells out to curl through io.popen and +-- reads the pipe to EOF, so refreshing the mod index, checking one mod's +-- releases, or opening the Find Mods tab froze the window for as long as the +-- server took -- measured at over two minutes on a cold Find Mods open, with +-- no spinner, no progress and no way to cancel, because the frame that would +-- have drawn them never ran. The self-updater already did this correctly on +-- a worker (src/update/check_worker.lua); this generalises that pattern so +-- everything else can follow it. +-- +-- CONTRACT. Callers get an opaque job id back immediately and poll it: +-- local job = Fetch.get(url) +-- ... +-- local st = Fetch.poll(job) -- { status, body, err, progress } +-- if st.status == "ok" then ... end +-- status is: pending | ok | error | cancelled. poll() never blocks and +-- never throws. A job's result is retained until Fetch.release(job), so a +-- caller that polls once per frame cannot miss it. +-- +-- DEGRADATION. With no love.thread (the headless test stub), no curl and no +-- Android bridge, jobs complete immediately with status "error" and a reason. +-- The UI shows that as a failed fetch, which is the same path an offline +-- machine takes -- there is no code path where the launcher waits forever. + +local Fetch = {} + +local CMD = "fetch_cmd" +local RESULT = "fetch_result" + +-- Worker count. Three is enough to overlap the common burst (a mod index +-- refresh plus a couple of per-mod release checks) without spawning a thread +-- per row on a 200-mod list; extra jobs queue on the channel. +local POOL = 3 + +local workers = {} +local cmdCh, resCh +local ready -- nil = untried, true = running, false = unavailable +local jobs = {} -- id -> { status, body, err, progress, path } +local nextId = 0 +local unavailableReason + +local function ensureWorkers() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread) then + ready, unavailableReason = false, "background threads unavailable" + return false + end + cmdCh = love.thread.getChannel(CMD) + resCh = love.thread.getChannel(RESULT) + for i = 1, POOL do + local ok, th = pcall(love.thread.newThread, "src/net/fetch_worker.lua") + if ok and th and pcall(function() th:start() end) then + workers[#workers + 1] = th + end + end + if #workers == 0 then + ready, unavailableReason = false, "could not start fetch workers" + return false + end + ready = true + return true +end + +-- Move every finished result off the channel into the job table. Called by +-- poll() and pending(), so a caller that polls any job drains all of them. +local function drain() + if not resCh then return end + local msg = resCh:pop() + while msg do + if type(msg) == "table" and msg.id then + local j = jobs[msg.id] + if j and j.status == "pending" then + if msg.progress and not msg.done then + j.progress = msg.progress + else + j.status = msg.ok and "ok" or "error" + j.body, j.err, j.path = msg.body, msg.err, msg.path + j.progress = msg.ok and 1 or j.progress + end + end + end + msg = resCh:pop() + end + -- A worker that died takes its in-flight job with it; surface that rather + -- than leaving the job pending forever (which would hang a loader overlay). + for _, th in ipairs(workers) do + local err = th:getError() + if err then + for _, j in pairs(jobs) do + if j.status == "pending" then + j.status, j.err = "error", tostring(err) + end + end + break + end + end +end + +local function submit(cmd) + nextId = nextId + 1 + local id = nextId + cmd.id = id + jobs[id] = { status = "pending", progress = 0 } + if not ensureWorkers() then + jobs[id].status = "error" + jobs[id].err = unavailableReason + return id + end + cmdCh:push(cmd) + return id +end + +-- GET a URL, returning the body as a string. +-- opts: { userAgent, accept } +function Fetch.get(url, opts) + opts = opts or {} + return submit({ kind = "get", url = url, + userAgent = opts.userAgent or "gen1recomp", + accept = opts.accept }) +end + +-- Download a URL to `saveRel`, a path relative to the LOVE save directory. +-- Progress is reported as a 0..1 fraction when `size` is known. +function Fetch.download(url, saveRel, opts) + opts = opts or {} + return submit({ kind = "download", url = url, dest = saveRel, + size = opts.size, + userAgent = opts.userAgent or "gen1recomp", + accept = opts.accept }) +end + +-- Non-blocking status. Returns a table; never nil, even for an unknown id +-- (an unknown id reads as an error, so a caller that dropped its handle +-- cannot deadlock a loader). +local MISSING = { status = "error", err = "unknown job" } +function Fetch.poll(id) + drain() + return jobs[id] or MISSING +end + +function Fetch.isPending(id) + return Fetch.poll(id).status == "pending" +end + +-- Forget a finished job. Callers should do this once they have consumed the +-- result, or the table grows for the life of the process. +function Fetch.release(id) + jobs[id] = nil +end + +-- Mark a job cancelled on the main thread. The worker's curl is NOT killed +-- (there is no portable way to signal it), but the result is dropped when it +-- lands, so a cancelled download cannot resurrect a closed overlay. +function Fetch.cancel(id) + local j = jobs[id] + if j and j.status == "pending" then j.status = "cancelled" end +end + +-- True while any job is still running -- drives the "working" indicator in +-- the launcher chrome. +function Fetch.busy() + drain() + for _, j in pairs(jobs) do + if j.status == "pending" then return true end + end + return false +end + +function Fetch.available() + return ensureWorkers() +end + +-- End every worker. Their command loops sit in Channel:demand(), which never +-- returns on its own, and LOVE waits for every live love.thread before the +-- process exits (#339). +function Fetch.shutdown() + if cmdCh then + for _ = 1, #workers do cmdCh:push({ kind = "quit" }) end + end + for _, th in ipairs(workers) do pcall(function() th:wait() end) end + workers = {} + cmdCh, resCh, ready = nil, nil, false +end + +return Fetch diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua new file mode 100644 index 00000000..ad028098 --- /dev/null +++ b/src/net/fetch_worker.lua @@ -0,0 +1,104 @@ +-- Worker thread behind src/net/Fetch.lua. Several of these run as a pool. +-- +-- Pulls jobs off the shared "fetch_cmd" channel and pushes results onto +-- "fetch_result". Every job is wrapped in pcall: a worker that dies takes +-- its in-flight job with it, and Fetch surfaces that as an error rather than +-- leaving a loader overlay spinning forever. +-- +-- Transport is HostShell, so this inherits the platform matrix that already +-- exists (curl on desktop, the JNI bridge on Android). Fresh love threads do +-- not carry the "src.*" package searcher, so HostShell is pulled in with +-- love.filesystem.load exactly like src/update/check_worker.lua does. + +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") + +local function loadModule(path) + local ok, chunk = pcall(love.filesystem.load, path) + if not ok or type(chunk) ~= "function" then return nil end + local ok2, mod = pcall(chunk) + if not ok2 then return nil end + return mod +end + +local HostShell = loadModule("src/core/HostShell.lua") + +local cmdCh = love.thread.getChannel("fetch_cmd") +local resCh = love.thread.getChannel("fetch_result") + +local saveDir = love.filesystem.getSaveDirectory() + +-- See the note in doGet: these bound how long a quit can block. A mod index +-- or a release list is a small JSON document, and a mod zip is a few MB; the +-- old 300s download ceiling was sized for the self-updater's whole payload, +-- which does not come through this pool. +local GET_MAX_SECONDS = 20 +local DOWNLOAD_MAX_SECONDS = 90 + +local function post(t) resCh:push(t) end + +local function doGet(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + -- Bounded transfer time: a worker inside a blocking curl cannot see a quit + -- command, and LOVE waits for live threads before exiting (#339), so this + -- ceiling is also the worst case for how long closing the window can take. + local body, err = HostShell.httpGet(job.url, job.userAgent, job.accept, + GET_MAX_SECONDS) + if not body then + post({ id = job.id, ok = false, err = err or "fetch failed" }) + return + end + post({ id = job.id, ok = true, body = body }) +end + +-- Downloads go straight to the save directory. HostShell.httpDownload +-- blocks until curl exits, which is fine here -- this is the whole reason +-- the work is on a worker -- but it means progress cannot be sampled from +-- inside the call. Where the caller knows the expected size we poll the +-- growing file from a second pass instead; where it does not, the job simply +-- reports indeterminate progress and the UI shows a spinner. +local function doDownload(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + local rel = job.dest + local abs = saveDir .. "/" .. rel + local dir = rel:match("^(.*)/[^/]*$") + if dir then love.filesystem.createDirectory(dir) end + love.filesystem.remove(rel) + + local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent, + job.accept, DOWNLOAD_MAX_SECONDS) + if not ok then + post({ id = job.id, ok = false, err = err or "download failed" }) + return + end + local info = love.filesystem.getInfo(rel) + if not info or (info.size or 0) == 0 then + love.filesystem.remove(rel) + post({ id = job.id, ok = false, err = "empty download" }) + return + end + post({ id = job.id, ok = true, path = rel, done = true }) +end + +while true do + local job = cmdCh:demand() + if type(job) == "table" then + if job.kind == "quit" then + break + elseif job.kind == "get" then + local ok, err = pcall(doGet, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + elseif job.kind == "download" then + local ok, err = pcall(doDownload, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + end + end +end diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua new file mode 100644 index 00000000..127e0fc9 --- /dev/null +++ b/src/ui/kit/Kit.lua @@ -0,0 +1,917 @@ +-- Immediate-mode widget kit shared by the launcher and the save editor. +-- +-- This is the replacement for the vendored FlexLove tree that the launcher +-- used to rebuild every frame. The contract that mattered there is kept -- +-- the UI is rebuilt from owner state each frame, so it can never drift -- +-- but without a retained element tree, per-element id hashing, or a +-- property snapshot pass. Measured effect on the launcher's build+draw: +-- ~9.2ms/frame down to well under 1ms (see POKEPORT_LAUNCHER_PROF). +-- +-- Usage, once per love.draw(): +-- Kit.layout(w, h) -- fonts + scale, only on resize +-- Kit.beginFrame(mx, my, clicked, wheel) +-- ... widgets ... +-- Kit.endFrame() +-- +-- WHY IT IS FAST (the rules any new widget must follow): +-- 1. No allocation in the steady state. Widgets take and return scalars; +-- the per-frame tables that do exist (nav list, audit) are reused and +-- truncated, never rebuilt. LuaJIT's GC is the difference between a +-- 6ms frame and a 0.6ms one when a list has 200 rows. +-- 2. Text is cached as love.graphics.Text objects keyed by font+string +-- (Kit.text). G.print re-shapes the string every call; a Text object +-- shapes once and then costs one batched draw. Colour is applied at +-- draw time, which does NOT break the batch -- switching FONTS does, +-- which is the other reason the cache pays. +-- 3. Measurement (font:getWidth, ellipsize) is memoised per font+string. +-- Ellipsising is O(glyphs) with a getWidth per step and list rows do it +-- for every visible cell, every frame, on strings that never change. +-- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod +-- index costs exactly what a 10-mod one does. There is no virtualised +-- scroller and no momentum integrator to run. +-- 5. Draw flat. No stencil, no mesh, no blend-mode change, no rounded +-- corners (see Theme.lua) -- every one is a pipeline flush. +-- +-- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse, +-- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter), +-- and gamepad (the same ring, driven by the d-pad, plus a virtual cursor). +-- Hit testing is a plain rect with no z-order, so overlapping layers must be +-- drawn in dispatch order and a modal raises Kit.blockClicks over what it +-- covers. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL + +local Kit = {} +Kit.Theme = Theme +Kit.PAL = PAL + +Kit.mouseX, Kit.mouseY = 0, 0 +Kit.mouseClicked = false -- left button pressed this frame +Kit.mouseDown = false -- held, polled (drag / press-and-hold) +Kit.wheelY = 0 -- wheel notches queued since the last frame +Kit.focus = nil -- id of the text field receiving keystrokes +Kit.focusId = nil -- id of the keyboard/gamepad focus ring target +Kit.time = 0 +Kit.fonts = {} +Kit.scale = 1 +Kit.blockClicks = false +Kit.audit = nil + +local G = love and love.graphics or nil +local edits = {} -- queued textinput / backspace since the last frame +local kbField = nil -- id of the field the soft keyboard is raised for + +local function has(name) + return Theme.probe(name) +end + +-- ------------------------------------------------------------ soft keyboard +-- Mobile LOVE only delivers love.textinput while setTextInput(true) is +-- active, and that call is what raises the Android/iOS soft keyboard; the +-- rect keeps the focused field visible above it. setTextInput is global SDL +-- state, not per-widget, so desktop text input is never turned off (#529). +local function mobile() + local osName = love and love.system and love.system.getOS + and love.system.getOS() + return osName == "Android" or osName == "iOS" +end +Kit.isMobile = mobile + +local function syncSoftKeyboard(id, x, y, w, h) + if not (love and love.keyboard and love.keyboard.setTextInput) then return end + if id then + if kbField ~= id then + kbField = id + love.keyboard.setTextInput(true, math.floor(x), math.floor(y), + math.ceil(w), math.ceil(h)) + end + elseif kbField then + kbField = nil + if mobile() then love.keyboard.setTextInput(false) end + end +end + +-- ------------------------------------------------------------- text caching +-- Two caches, both keyed by font name + string, both cleared wholesale when +-- the font set is rebuilt (a resize). A wholesale clear is correct and +-- cheap: an LRU would cost more bookkeeping per lookup than it saves, and +-- the working set of a UI is small and stable between resizes. +local textCache, textCacheN = {}, 0 +local widthCache = {} +local ellipsisCache = {} +local CACHE_MAX = 1024 + +local wrapCacheRef -- forward declaration; the table is defined below +local function clearCaches() + textCache, textCacheN = {}, 0 + widthCache = {} + ellipsisCache = {} + if wrapCacheRef then + for k in pairs(wrapCacheRef) do wrapCacheRef[k] = nil end + end +end +Kit.clearCaches = clearCaches + +local function font(name) + return Kit.fonts[name] or Kit.fonts.small +end +Kit.font = font + +-- Rebuild the font set when the window size changes. The scale never dips +-- below 0.9 so text and the 30px tap targets stay readable on a phone; a +-- narrow window is answered by REFLOW (see Layout.lua), never by shrinking. +-- Global size multiplier. Everything in the UI derives from Kit.scale, so +-- one factor here moves text, tap targets, padding and row heights together +-- and nothing drifts out of proportion. 1.3 because the launcher is read at +-- couch distance as often as at desk distance, and the old sizing was tuned +-- for the latter only. +local UI_SCALE = 1.3 + +function Kit.layout(width, height) + local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE + local key = ("%dx%d"):format(math.floor(width), math.floor(height)) + if Kit._fontKey ~= key then + Kit._fontKey = key + Kit.fonts = Theme.fonts(s) + clearCaches() -- every cached Text/width belongs to the old font set + end + Kit.scale = s + Kit.width, Kit.height = width, height + return s +end + +function Kit.textWidth(name, str) + str = tostring(str) + local key = name .. "\0" .. str + local w = widthCache[key] + if w then return w end + local f = font(name) + -- Never let a malformed string (a mod name from a third-party index) throw + -- out of a measurement: an unmeasurable string is treated as zero-width and + -- the ellipsis logic clips it away. + if f then + local ok, got = pcall(f.getWidth, f, str) + w = ok and got or 0 + else + w = 0 + end + widthCache[key] = w + return w +end + +function Kit.textHeight(name) + local f = font(name) + return f and f:getHeight() or 12 +end + +function Kit.ellipsize(name, str, maxW) + str = tostring(str or "") + local key = name .. "\0" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsize(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +function Kit.ellipsizeLeft(name, str, maxW) + str = tostring(str or "") + local key = name .. "\1" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsizeLeft(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +-- A cached, pre-shaped Text object. Falls back to G.print under a stub or +-- when the cache is saturated. +local function textObject(name, str) + if not (G and has("newText")) then return nil end + local key = name .. "\0" .. str + local t = textCache[key] + if t then return t end + if textCacheN >= CACHE_MAX then clearCaches() end + local f = font(name) + if not f then return nil end + local ok, obj = pcall(G.newText, f, str) + if not ok then return nil end + textCache[key] = obj + textCacheN = textCacheN + 1 + return obj +end + +-- Bold text: the same cached run drawn twice, one pixel apart. The UI face +-- has a single weight, so this is the only way to get emphasis without +-- shipping a second font -- and it keeps the measurement identical, which +-- matters because every layout here is measured, not flowed. +function Kit.textBold(name, str, x, y, c, a) + local w = Kit.text(name, str, x, y, c, a) + Kit.text(name, str, x + Theme.BOLD_OFFSET, y, c, a) + return w +end + +function Kit.textCenterBold(name, str, x, y, w, c, a) + local tw = Kit.textWidth(name, tostring(str)) + return Kit.textBold(name, str, x + (w - tw) / 2, y, c, a) +end + +-- Draw a string. Returns its width, so callers can lay out inline runs +-- without a second measurement. +function Kit.text(name, str, x, y, c, a) + if not G then return 0 end + str = tostring(str) + Theme.col(c or PAL.text, a or 1) + local obj = textObject(name, str) + if obj then + G.draw(obj, Theme.snap(x), Theme.snap(y)) + else + local f = font(name) + if not f then return 0 end + G.setFont(f) + -- Same guard as the measurement path: a string LOVE cannot shape must + -- not take the whole frame down with it. + pcall(G.print, str, Theme.snap(x), Theme.snap(y)) + end + return Kit.textWidth(name, str) +end + +function Kit.textRight(name, str, x2, y, c, a) + return Kit.text(name, str, x2 - Kit.textWidth(name, tostring(str)), y, c, a) +end + +function Kit.textCenter(name, str, x, y, w, c, a) + return Kit.text(name, str, x + (w - Kit.textWidth(name, tostring(str))) / 2, + y, c, a) +end + +-- Word-wrapped text. Font:getWrap re-shapes the whole string every call and +-- list rows ask for the same (font, width, string) every frame, so the line +-- split is memoised alongside the other measurement caches. `maxLines` +-- truncates with an ellipsis rather than overflowing the box the caller +-- reserved -- an immediate-mode layout has no way to grow after the fact. +local wrapCache = {} +wrapCacheRef = wrapCache + +function Kit.wrapLines(name, str, w) + str = tostring(str or "") + if str == "" or w <= 0 then return nil end + local key = name .. "\0" .. math.floor(w) .. "\0" .. str + local lines = wrapCache[key] + if lines then return lines end + local f = font(name) + if not f then return nil end + local ok, _, wrapped = pcall(f.getWrap, f, str, w) + lines = (ok and wrapped) or { str } + wrapCache[key] = lines + return lines +end + +-- Returns the height consumed. +function Kit.textWrapped(name, str, x, y, w, c, maxLines, a) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local lh = Kit.textHeight(name) + local n = #lines + if maxLines and n > maxLines then n = maxLines end + for i = 1, n do + local line = lines[i] + if maxLines and i == maxLines and #lines > maxLines then + line = Kit.ellipsize(name, line .. "...", w) + end + Kit.text(name, line, x, y + (i - 1) * lh, c, a) + end + return n * lh +end + +-- Height a wrapped run will need, without drawing it. Panels call this to +-- reserve space before laying the block out. +function Kit.wrapHeight(name, str, w, maxLines) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local n = #lines + if maxLines and n > maxLines then n = maxLines end + return n * Kit.textHeight(name) +end + +-- 12px / 2px-tracked uppercase section caption -- the design's one and only +-- section header. Returns its height so callers can stack below. +function Kit.caption(x, y, str, c) + if not G then return Kit.textHeight("caption") end + local f = font("caption") + if not f then return 12 end + G.setFont(f) + Theme.col(c or PAL.caption, 1) + Theme.spaced(f, str, Theme.snap(x), Theme.snap(y), 2 * Kit.scale) + return f:getHeight() +end + +function Kit.captionWidth(str) + return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale) +end + +-- ------------------------------------------------------------- frame cycle +function Kit.beginFrame(mx, my, clicked, wheel) + Kit.mouseX, Kit.mouseY = mx or 0, my or 0 + Kit.mouseClicked = clicked and true or false + Kit.wheelY = wheel or 0 + local down = false + if love and love.mouse and love.mouse.isDown then + down = love.mouse.isDown(1) and true or false + end + Kit.mouseDown = down + if not down then Kit._drag = nil end + Kit.resetClip() + Kit.blockClicks = false + if love and love.timer and love.timer.getTime then + Kit.time = love.timer.getTime() + end + -- Resolve any queued focus-ring movement against LAST frame's geometry. + -- Immediate mode has no geometry until the frame is built, and the ring + -- must move before widgets test themselves against it. + Kit._resolveNav() + -- Start collecting this frame's focusables. + Kit._navN = 0 +end + +-- Retire this frame's keystrokes, wheel notches and one-shot activations. +-- Anything typed while no field had focus is dropped here rather than +-- replayed into the next field that gets clicked. +function Kit.endFrame() + for i = #edits, 1, -1 do edits[i] = nil end + Kit.wheelY = 0 + Kit._activateId = nil + -- This frame's focusables become next frame's navigation graph. + local n = Kit._navN or 0 + Kit._navPrevN = n + -- If the focused id vanished (panel switch, list repaged), park the ring + -- on the first focusable so the keyboard is never stranded. + if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then + Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil + end + for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end +end + +-- ------------------------------------------------------------ focus ring +-- Spatial navigation. Every focusable control registers its rect as it +-- draws; a queued direction picks the nearest candidate in that direction +-- from the previous frame's set. Spatial rather than index-order because +-- the launcher is a multi-column layout: tab-order would zigzag between +-- columns, while "press right, go right" is what both a keyboard and a +-- d-pad user expects. +Kit._nav = {} +Kit._navN = 0 +Kit._navPrevN = 0 +Kit._navSeen = {} +Kit._navQueue = nil +Kit._activateId = nil + +-- Register a focusable. Returns true when it currently holds the ring. +function Kit.focusable(id, x, y, w, h) + local n = (Kit._navN or 0) + 1 + Kit._navN = n + local slot = Kit._nav[n] + if not slot then slot = {}; Kit._nav[n] = slot end + slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h + Kit._navSeen[id] = true + -- First focusable ever drawn adopts the ring, so keyboard users start + -- somewhere rather than nowhere. + if Kit.focusId == nil then Kit.focusId = id end + return Kit.focusId == id +end + +function Kit.navigate(dir) + Kit._navQueue = dir +end + +function Kit.activateFocused() + if Kit.focusId then Kit._activateId = Kit.focusId end +end + +function Kit.setFocus(id) + Kit.focusId = id +end + +-- Pick the nearest focusable in `dir` from the current one. Candidates must +-- lie in the half-plane of the direction; the score prefers a small step +-- along the axis of travel and penalises drift across it, which keeps a +-- column walk inside its column. +function Kit._resolveNav() + local dir = Kit._navQueue + Kit._navQueue = nil + local n = Kit._navPrevN or 0 + if not dir or n == 0 then return end + local cur + for i = 1, n do + if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end + end + if not cur then + Kit.focusId = Kit._nav[1].id + return + end + local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id then + local dx = (c.x + c.w / 2) - cx + local dy = (c.y + c.h / 2) - cy + local along, across + if dir == "left" then along, across = -dx, math.abs(dy) + elseif dir == "right" then along, across = dx, math.abs(dy) + elseif dir == "up" then along, across = -dy, math.abs(dx) + else along, across = dy, math.abs(dx) end + -- A control merely overlapping on the travel axis is not "in that + -- direction"; require real separation so a tall row's neighbours do + -- not all qualify. + if along > 1 then + local score = along + across * 2 + if not bestScore or score < bestScore then best, bestScore = c, score end + end + end + end + if best then Kit.focusId = best.id end +end + +-- ------------------------------------------------------------ input plumbing +function Kit.textinput(text) + if not Kit.focus then return false end + edits[#edits + 1] = text + return true +end + +-- Returns true when the key was consumed, so the host can leave its own +-- shortcuts alone while the user is typing or driving the ring. +function Kit.keypressed(key) + if Kit.focus then + if key == "backspace" then edits[#edits + 1] = "\b" return true + elseif key == "return" or key == "kpenter" or key == "escape" then + edits[#edits + 1] = "\r" return true + end + -- printable keys arrive through textinput; everything else falls through + return false + end + if key == "up" or key == "down" or key == "left" or key == "right" then + Kit.navigate(key) + return true + elseif key == "return" or key == "kpenter" or key == "space" then + Kit.activateFocused() + return true + end + return false +end + +-- Gamepad d-pad / stick, routed by the host's pad handling. +function Kit.gamepadpressed(button) + if button == "dpup" then Kit.navigate("up") return true + elseif button == "dpdown" then Kit.navigate("down") return true + elseif button == "dpleft" then Kit.navigate("left") return true + elseif button == "dpright" then Kit.navigate("right") return true + elseif button == "a" then Kit.activateFocused() return true end + return false +end + +function Kit.blur() + Kit.focus = nil + syncSoftKeyboard(nil) +end + +-- -------------------------------------------------------------- hit testing +-- A widget inside a clip region can sit at coordinates outside the visible +-- rect, so the active clip bounds the hit: what the user cannot see cannot +-- take the tap. +function Kit.hit(x, y, w, h) + local c = Kit._clipRect + if c and not (Kit.mouseX >= c.x and Kit.mouseX <= c.x + c.w + and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then + return false + end + return Kit.mouseX >= x and Kit.mouseX <= x + w + and Kit.mouseY >= y and Kit.mouseY <= y + h +end + +function Kit.hover(x, y, w, h) + return Kit.hit(x, y, w, h) +end + +function Kit.press(x, y, w, h) + if Kit.blockClicks then return false end + return Kit.mouseClicked and Kit.hit(x, y, w, h) +end + +-- Layout audit: when a test sets Kit.audit to a table, every control that +-- could take a click this frame appends its rect (plus the clip that bounds +-- it), so a window-size sweep can assert no two controls overlap and none +-- escapes the window. Shielded widgets are skipped: under a modal they +-- cannot take the tap, and the modal legitimately covers them. +local function audit(class, x, y, w, h, label) + local a = Kit.audit + if not a or Kit.blockClicks then return end + local c = Kit._clipRect + a[#a + 1] = { class = class, x = x, y = y, w = w, h = h, + label = tostring(label or ""), + clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil } +end +Kit._audit = audit + +-- ------------------------------------------------------------------ metrics +-- Minimum tap target. 30px at scale 1 (up from the editor's 26) because the +-- launcher is the first thing a phone user touches and these are the only +-- controls that matter. +function Kit.tapMin() return math.floor(30 * Kit.scale) end + +-- ---------------------------------------------------------------- surfaces +function Kit.card(x, y, w, h, emphasis) + Theme.card(x, y, w, h, emphasis) +end + +-- A list row. `id` opts it into the focus ring; pass nil for decorative +-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the +-- caller must print with the returned ink or it will draw white on white. +function Kit.row(x, y, w, h, selected, id) + audit("row", x, y, w, h, id or "row") + local focused = id and Kit.focusable(id, x, y, w, h) or false + local hot = Kit.hover(x, y, w, h) + local state = selected and "selected" or (hot and "hover" or nil) + local ink = Theme.row(x, y, w, h, state) + -- The focus ring is a second inset outline, so it reads on both a black + -- row and a white selected one. + if focused then + Theme.stroke(x + 2, y + 2, w - 4, h - 4, + selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1) + end + local clicked = Kit.press(x, y, w, h) + or (id ~= nil and Kit._activateId == id) + return clicked, ink +end + +-- Empty-state box: hairline outline and a centred hint. (The old dashed +-- border sampled a rounded path into a polyline every frame; a solid +-- hairline says the same thing for one rect.) +function Kit.emptyBox(x, y, w, h, message) + if not G then return end + Theme.stroke(x, y, w, h, PAL.line, 0.22, 1) + Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), + x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) +end + +-- ----------------------------------------------------------------- buttons +-- Button kinds. In a black/white theme the semantics live in the OUTLINE +-- and INK colour; the fill is black until the control is hot or focused, at +-- which point it inverts to a solid fill with dark ink. That inversion is +-- the single strongest contrast signal available and costs one rect. +-- `solid` means the control is filled even at rest: reserved for the single +-- most important action on a screen (Play), which should not have to be +-- hovered before it looks like the answer. +-- Buttons are COLOUR-CODED by what they do, so a control's job is readable +-- before its label is. The button IS the colour: a solid fill with black +-- ink, not an outline with coloured text. Against a black field a filled +-- chip is the strongest, fastest-to-scan signal available, and every accent +-- in this palette is high-luminance, so black ink on it clears contrast +-- requirements comfortably. +-- primary green -- the commit action (Play, Save, Install) +-- good green -- safe helpers +-- accent blue -- navigation / information (Details, Edit, Import) +-- warn yellow -- attention (an update is waiting) +-- danger red -- destructive, always two-press +-- ghost white -- neutral verbs with no better colour +-- disabled grey -- never hidden, always still readable +-- Hover/focus is a white ring around the fill (plus a slight lift), which +-- reads on every colour without needing a second shade of each. +local KINDS = { + primary = { fill = PAL.green, ink = PAL.inverse }, + good = { fill = PAL.green, ink = PAL.inverse }, + accent = { fill = PAL.blue, ink = PAL.inverse }, + warn = { fill = PAL.yellow, ink = PAL.inverse }, + danger = { fill = PAL.red, ink = PAL.inverse }, + ghost = { fill = PAL.ink, ink = PAL.inverse }, + disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true }, +} +Kit.KINDS = KINDS + +-- opts: { kind, font, enabled, align, id, glow } +-- id -- opts into the focus ring (give every real control one) +-- glow -- a pulsing outline for "something is waiting for you" (the +-- update button). No blend-mode change: the alpha of the +-- existing outline is animated instead. +-- Returns true when activated, by click OR by the focus ring's Enter/A. +function Kit.button(x, y, w, h, label, opts) + opts = opts or {} + local enabled = opts.enabled ~= false + -- Disabled buttons audit too: they stay visible, so they still must not + -- paint over a neighbour. + audit("control", x, y, w, h, label) + local focused = enabled and opts.id + and Kit.focusable(opts.id, x, y, w, h) or false + local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + local hot = enabled and Kit.hover(x, y, w, h) + + if G then + -- The fill IS the control: a rounded, embossed, colour-coded key. A + -- disabled button keeps its shape in a dead grey rather than + -- disappearing, so a layout never reflows on state. + Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) + Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) + if hot or focused then + -- White ring outside the fill: legible on green, blue, yellow, red and + -- white alike, which one darker/lighter shade per colour would not be. + Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, + Theme.A.focus, 2, Theme.radius() + 2) + elseif opts.glow and enabled then + -- "Something is waiting for you" (the update button): a pulsing ring. + -- Pure alpha on one existing stroke -- no extra draw calls, no blend + -- mode change. + local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3)) + Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2, + Theme.radius() + 2) + end + local fname = opts.font or "button" + local ink = enabled and kind.ink or PAL.inverse + local ty = y + (h - Kit.textHeight(fname)) / 2 + local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale) + -- Button labels are bold: they are the shortest, most-scanned text on + -- screen and sit on a saturated fill. + if opts.align == "left" then + Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink) + else + Kit.textCenterBold(fname, shown, x, ty, w, ink) + end + end + if not enabled then return false end + return Kit.press(x, y, w, h) + or (opts.id ~= nil and Kit._activateId == opts.id) +end + +-- A small square control: +/- steppers, arrow cyclers, the row X. +function Kit.stepper(x, y, w, h, glyph, opts) + opts = opts or {} + opts.kind = opts.kind or "ghost" + opts.font = opts.font or "small" + return Kit.button(x, y, w, h, glyph, opts) +end + +-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it. +function Kit.chip(x, y, w, h, label, on, color, id) + audit("control", x, y, w, h, label) + local focused = id and Kit.focusable(id, x, y, w, h) or false + local c = color or PAL.line + if G then + local hot = focused or Kit.hover(x, y, w, h) + if on then + Theme.fillRounded(x, y, w, h, c, 1) + Theme.emboss(x, y, w, h, 1) + Kit.textCenterBold("micro", label, x, + y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse) + else + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, c, + hot and Theme.A.focus or Theme.A.hover, 1) + Kit.textCenterBold("micro", label, x, + y + (h - Kit.textHeight("micro")) / 2, w, c) + end + if hot then + Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, + Theme.A.focus, 2, Theme.radius() + 2) + end + end + return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) +end + +-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" +-- markers on mod rows. +function Kit.tag(x, y, w, h, label, color) + if not G then return end + Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1) + Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w, + color or PAL.muted) +end + +-- Checkbox row. Returns (newChecked, changed). +function Kit.checkbox(x, y, w, h, checked, label, id, labelColor) + local clicked, ink = Kit.row(x, y, w, h, false, id) + local box = 20 * Kit.scale + local bx, by = x + 12 * Kit.scale, y + (h - box) / 2 + if G then + if checked then + Theme.fill(bx, by, box, box, PAL.ink, 1) + Kit.textCenter("small", "X", bx, + by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse) + else + Theme.stroke(bx, by, box, box, PAL.line, Theme.A.hover, 1) + end + local lx = bx + box + 12 * Kit.scale + Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), + lx, y + (h - Kit.textHeight("mono")) / 2, + labelColor or ink or PAL.text) + end + if clicked then return not checked, true end + return checked, false +end + +-- A two-state switch, for the settings ladders. +function Kit.toggle(x, y, w, h, on, id) + audit("control", x, y, w, h, "toggle") + local focused = id and Kit.focusable(id, x, y, w, h) or false + if G then + -- Track, then a knob inset inside it, so the control reads as a switch + -- rather than as a white square with a word next to it. The label sits + -- in the empty half, which is the half that says what pressing does. + Theme.stroke(x, y, w, h, PAL.line, + (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1) + local inset = 3 + local knob = w / 2 - inset + Theme.fill(on and (x + w / 2) or (x + inset), y + inset, knob, h - 2 * inset, + PAL.ink, 1) + Kit.textCenter("micro", on and "ON" or "OFF", + on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2, + PAL.text) + end + local hitTaken = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) + if hitTaken then return not on, true end + return on, false +end + +-- A determinate progress bar with an optional caption. +function Kit.progress(x, y, w, h, frac, label) + Theme.meter(x, y, w, h, (frac or 0) * 100, PAL.ink) + if label then + Kit.text("micro", label, x, y + h + 4 * Kit.scale, PAL.muted) + end +end + +-- --------------------------------------------------------------- text field +function Kit.textfield(id, x, y, w, h, value, placeholder) + audit("control", x, y, w, h, id) + local focusRing = Kit.focusable(id, x, y, w, h) + value = tostring(value or "") + if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end + local focused = (Kit.focus == id) + if focused then + syncSoftKeyboard(id, x, y, w, h) + for _, e in ipairs(edits) do + if e == "\b" then + value = value:sub(1, -2) + elseif e == "\r" then + Kit.blur() + focused = false + else + value = value .. e + end + end + end + if G then + Theme.fill(x, y, w, h, PAL.bg, 1) + Theme.stroke(x, y, w, h, PAL.line, + (focused or focusRing) and Theme.A.focus or Theme.A.hairline, + focused and 2 or 1) + local pad = 10 * Kit.scale + local ty = y + (h - Kit.textHeight("mono")) / 2 + if value == "" and not focused then + Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint) + else + local shown = Kit.ellipsizeLeft("mono", value, w - 2 * pad) + local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading) + if focused and (Kit.time % 1) < 0.55 then + Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale), + Kit.textHeight("mono"), PAL.ink, 1) + end + end + end + return value +end + +-- -------------------------------------------------------------------- pager +-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is +-- never silently truncated. This is the ONLY way the launcher moves through +-- a long list: no scrollbars, no momentum, bounded row count per frame. +-- Returns the new page (1-based) and the row height consumed. +function Kit.pager(x, y, w, page, total, perPage, idPrefix) + local h = math.max(Kit.tapMin(), 30 * Kit.scale) + local bw = 74 * Kit.scale + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local gap = 8 * Kit.scale + idPrefix = idPrefix or "pager" + + if Kit.button(x, y, bw, h, "< Prev", { kind = "ghost", font = "small", + enabled = page > 1, id = idPrefix .. ":prev" }) then + page = math.max(1, page - 1) + end + if Kit.button(x + bw + gap, y, bw, h, "Next >", { kind = "ghost", + font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then + page = math.min(pages, page + 1) + end + + local first = total > 0 and ((page - 1) * perPage + 1) or 0 + local last = math.min(total, page * perPage) + local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages) + local labelX = x + 2 * bw + 2 * gap + gap + Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)), + labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption) + return page, h +end + +-- Slice helper so callers never hand-roll page arithmetic (and never draw a +-- row that is off the page -- the entire performance claim rests on this). +function Kit.pageBounds(page, total, perPage) + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local first = (page - 1) * perPage + 1 + local last = math.min(total, page * perPage) + return first, last, page, pages +end + +-- How many rows of `rowH` (plus `gap`) fit in `h` pixels. Panels call this +-- to derive perPage from the real viewport instead of a magic number, so a +-- tall window shows more rows and a phone shows fewer -- with no scrolling +-- either way. +function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) + local per = math.floor((h + (gap or 0)) / math.max(1, rowH + (gap or 0))) + return math.max(minRows or 1, math.min(maxRows or 99, per)) +end + +-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do +-- something (users expect it), but it moves a bounded page index rather than +-- driving a pixel offset, so there is no scroll state and no interpolation. +function Kit.wheelPage(x, y, w, h, page, total, perPage) + if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end + if not Kit.hit(x, y, w, h) then return page end + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) + Kit.wheelY = 0 + return math.floor(moved) +end + +-- ------------------------------------------------------------------ spinner +-- The one animated element in the UI: a rotating arc of ticks. Drawn as N +-- short lines at descending alpha, which needs no shader, no canvas and no +-- blend-mode change. `t` defaults to the frame clock so every spinner on +-- screen stays in phase. +function Kit.spinner(cx, cy, r, t) + if not G or not has("line") then return end + t = t or Kit.time + local ticks = 12 + local step = (math.pi * 2) / ticks + local head = math.floor((t * 10) % ticks) + if has("setLineWidth") then G.setLineWidth(math.max(2, 2 * Kit.scale)) end + for i = 0, ticks - 1 do + local a = ((ticks - ((i - head) % ticks)) / ticks) + local ang = i * step - math.pi / 2 + local c, s = math.cos(ang), math.sin(ang) + Theme.col(PAL.ink, a * a) + G.line(cx + c * r * 0.55, cy + s * r * 0.55, cx + c * r, cy + s * r) + end + if has("setLineWidth") then G.setLineWidth(1) end +end + +-- ------------------------------------------------------------------- clip +-- Clip drawing to a rect. A stack: pushes intersect with the rect above and +-- a pop restores that rect rather than clearing the scissor, so a nested +-- region can never unclip its parent. The tracked rect also bounds Kit.hit, +-- so a widget clipped out of view is inert instead of taking taps aimed at +-- whatever is drawn where it left. +local clipStack = {} + +local function applyClip(rect) + Kit._clipRect = rect + if not (G and G.setScissor) then return end + if not rect then + G.setScissor() + elseif rect.w <= 0 or rect.h <= 0 then + -- LOVE rejects negative scissor dimensions; an exhausted clip region is + -- empty, not invalid. + G.setScissor(0, 0, 0, 0) + else + G.setScissor(math.floor(rect.x), math.floor(rect.y), + math.ceil(rect.w), math.ceil(rect.h)) + end +end + +function Kit.pushClip(x, y, w, h) + local prev = clipStack[#clipStack] + local x2, y2 = x + math.max(0, w), y + math.max(0, h) + if prev then + x, y = math.max(x, prev.x), math.max(y, prev.y) + x2 = math.min(x2, prev.x + prev.w) + y2 = math.min(y2, prev.y + prev.h) + end + local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) } + clipStack[#clipStack + 1] = rect + applyClip(rect) +end + +function Kit.popClip() + clipStack[#clipStack] = nil + applyClip(clipStack[#clipStack]) +end + +-- A pcall-ed draw that raised mid-clip must not leak the stack into later +-- frames (every hit test would stay fenced to the dead rect), so the frame +-- boundary clears it. +function Kit.resetClip() + for i = #clipStack, 1, -1 do clipStack[i] = nil end + applyClip(nil) +end + +return Kit diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua new file mode 100644 index 00000000..53abb929 --- /dev/null +++ b/src/ui/kit/Layout.lua @@ -0,0 +1,115 @@ +-- Shared layout metrics for the launcher and the save editor. +-- +-- Both windows derive one `m` table per frame from the real window size and +-- the platform safe area, and every panel lays itself out in explicit pixels +-- off that table. Explicit pixels are the point: the old view expressed +-- widths as "100%" and leaned on a layout engine to resolve them, which is +-- where the launcher's layout bugs lived (percentages resolving against a +-- border box instead of a content box, auto-sized children measuring zero +-- height inside an auto-sized parent, flex-shrink compressing text until it +-- overlapped). None of those failure modes exist when a column is simply +-- `math.floor((contentW - gap) / 2)`. +-- +-- REFLOW, not shrink: a narrow window drops to fewer columns rather than +-- scaling the desktop layout down. Scale has a floor (Kit.layout clamps to +-- 0.9) so tap targets and text stay legible on a phone. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local SafeArea = require("src.core.SafeArea") + +local Layout = {} + +-- Breakpoints, in safe-area pixels. Named so panels read intent rather than +-- magic numbers. +Layout.BP = { + twoCol = 640, -- side-by-side columns become possible + threeCol = 1100, -- wide desktop: mod list + detail + chrome +} + +-- Build the frame's metrics. `maxAppW` caps the content column on an +-- ultrawide monitor so the UI stays a readable measure instead of stretching. +function Layout.metrics(maxAppW) + local W, H = 0, 0 + if love and love.graphics and love.graphics.getDimensions then + W, H = love.graphics.getDimensions() + end + local ox, oy, sw, sh = SafeArea.rect() + local s = Kit.layout(sw, sh) + + local appW = math.min(sw, (maxAppW or 1200) * s) + local m = { + W = W, H = H, s = s, + x = math.floor(ox + (sw - appW) / 2), + top = math.floor(oy), + w = math.floor(appW), + h = math.floor(sh), + pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)), + gap = math.floor(12 * s), + colGap = math.floor(16 * s), + rowH = math.max(Kit.tapMin(), math.floor(44 * s)), + btnH = math.max(Kit.tapMin(), math.floor(38 * s)), + chip = math.max(Kit.tapMin(), math.floor(40 * s)), + railH = math.max(3, math.floor(4 * s)), + logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)), + } + m.cols = (appW >= Layout.BP.threeCol * s and 3) + or (appW >= Layout.BP.twoCol * s and 2) + or 1 + m.twoCol = m.cols >= 2 + m.contentW = m.w - 2 * m.pad + m.colW = m.twoCol + and math.floor((m.contentW - m.colGap) / 2) + or m.contentW + m.contentX = m.x + m.pad + return m +end + +-- A vertical cursor for stacking blocks down a column. Immediate mode has +-- no layout pass, so panels advance a y by hand; this makes that explicit +-- and keeps the arithmetic in one place. +local Cursor = {} +Cursor.__index = Cursor + +function Layout.cursor(x, y, w) + return setmetatable({ x = x, y = y, w = w, y0 = y }, Cursor) +end + +-- Reserve `h` pixels and return the rect that was reserved. +function Cursor:take(h, gapAfter) + local x, y = self.x, self.y + self.y = self.y + h + (gapAfter or 0) + return x, y, self.w, h +end + +function Cursor:skip(h) + self.y = self.y + h +end + +function Cursor:height() + return self.y - self.y0 +end + +-- Split the cursor's width into `n` equal columns with `gap` between them, +-- returning a function that yields the i-th column's x and width. +function Layout.columns(x, w, n, gap) + n = math.max(1, n) + local cw = math.floor((w - gap * (n - 1)) / n) + return function(i) + return x + (i - 1) * (cw + gap), cw + end +end + +-- Lay a row of buttons out right-aligned within [x, x+w], returning a +-- function that yields each button's x as it is consumed right to left. +function Layout.rightCluster(x, w, gap) + local cursor = x + w + return function(bw) + cursor = cursor - bw + local bx = cursor + cursor = cursor - gap + return bx + end +end + +return Layout diff --git a/src/ui/kit/Loader.lua b/src/ui/kit/Loader.lua new file mode 100644 index 00000000..5f4169ae --- /dev/null +++ b/src/ui/kit/Loader.lua @@ -0,0 +1,133 @@ +-- Non-dismissable loading overlays. +-- +-- The rule this module enforces: ANY operation that can make the UI wait -- +-- a network fetch, a ROM extraction, a mod install, an update check -- puts +-- something obvious on screen for its whole duration. The old launcher +-- failed this twice over: slow work ran synchronously on the main thread, so +-- the window simply stopped responding (the Find Mods tab could hang for +-- minutes with no indication it was doing anything at all), and the few +-- operations that did report progress did so as a small line of text. +-- +-- Two presentations: +-- Loader.overlay(...) a modal scrim + panel, for work the user must wait +-- on before doing anything else. It BLOCKS input +-- (Kit.blockClicks) and offers no dismiss control -- +-- that is deliberate, so a half-finished install can +-- never be clicked around. Cancellable work passes +-- an onCancel and gets exactly one Cancel button. +-- Loader.inline(...) a spinner + label sized to a control, for work that +-- only blocks part of the UI (a row's update check). +-- +-- Callers drive both from a state table; nothing here owns state or time, so +-- the same overlay renders identically in a screenshot test. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL + +local Loader = {} + +-- Scrim alpha. Not opaque: the user keeps the context of what they were +-- doing, which is most of why a modal beats a blank screen. +local SCRIM_A = 0.82 + +-- spec = { +-- title = "Fetching mod index", -- required, the verb in progress +-- detail = "index.json from ...", -- optional second line +-- progress = 0..1 or nil, -- nil = indeterminate (spinner) +-- count = "3 of 12", -- optional right-aligned counter +-- onCancel = function() end, -- optional; adds a Cancel button +-- cancelLabel = "Cancel", +-- } +-- Returns true when the cancel button was activated this frame. +function Loader.overlay(m, spec) + if not spec then return false end + local G = love and love.graphics + local W, H = m.W, m.H + + -- The scrim covers the whole window, not just the app column: a modal that + -- leaves the letterboxed margins live is a modal you can click around. + if G then + Theme.fill(0, 0, W, H, PAL.bg, SCRIM_A) + end + -- Everything drawn BEFORE this call is now shielded; the panel below + -- lowers the shield for its own controls. + Kit.blockClicks = true + + local pw = math.floor(math.min(m.w - 2 * m.pad, 460 * m.s)) + local ph = math.floor((spec.onCancel and 210 or 160) * m.s) + local px = math.floor((W - pw) / 2) + local py = math.floor((H - ph) / 2) + + Kit.card(px, py, pw, ph, true) + + local pad = math.floor(18 * m.s) + local cx = px + pw / 2 + + -- Spinner (indeterminate) or a progress bar (determinate). Never both. + local y = py + pad + if spec.progress then + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(14 * m.s) + Kit.progress(px + pad, y, pw - 2 * pad, math.floor(10 * m.s), spec.progress) + y = y + math.floor(10 * m.s) + math.floor(10 * m.s) + local pct = ("%d%%"):format(math.floor(spec.progress * 100 + 0.5)) + Kit.textCenter("small", pct, px + pad, y, pw - 2 * pad, PAL.detail) + y = y + Kit.textHeight("small") + math.floor(6 * m.s) + else + local r = math.floor(16 * m.s) + Kit.spinner(cx, y + r, r) + y = y + 2 * r + math.floor(14 * m.s) + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(6 * m.s) + end + + if spec.detail and spec.detail ~= "" then + Kit.textCenter("small", + Kit.ellipsize("small", spec.detail, pw - 2 * pad), + px + pad, y, pw - 2 * pad, PAL.muted) + y = y + Kit.textHeight("small") + math.floor(4 * m.s) + end + if spec.count and spec.count ~= "" then + Kit.textCenter("micro", spec.count, px + pad, y, pw - 2 * pad, PAL.faint) + end + + local cancelled = false + if spec.onCancel then + -- The one control a blocking overlay may have. It lives inside the + -- panel, so it is the only thing on screen that can take a click. + Kit.blockClicks = false + local bw = math.floor(math.min(pw - 2 * pad, 160 * m.s)) + local bh = m.btnH + if Kit.button(px + (pw - bw) / 2, py + ph - pad - bh, bw, bh, + spec.cancelLabel or "Cancel", + { kind = "ghost", id = "loader:cancel" }) then + cancelled = true + end + Kit.blockClicks = true + end + return cancelled +end + +-- A spinner plus label occupying a control-sized rect. Used in place of the +-- button that started the work, so the row does not reflow while it runs. +function Loader.inline(x, y, w, h, label) + local r = math.floor(math.min(h, 20 * Kit.scale) / 2) + local cx = x + r + 4 + Kit.spinner(cx, y + h / 2, r) + if label then + local lx = cx + r + 8 + Kit.text("small", Kit.ellipsize("small", label, math.max(0, x + w - lx)), + lx, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + end +end + +-- A tiny spinner sized to sit inside a text run (a mod row checking for +-- updates). Returns the width it consumed. +function Loader.dot(x, y, size) + local r = size / 2 + Kit.spinner(x + r, y + r, r) + return size +end + +return Loader diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua new file mode 100644 index 00000000..7e9c8f26 --- /dev/null +++ b/src/ui/kit/Theme.lua @@ -0,0 +1,381 @@ +-- High-contrast theme shared by the launcher (src/import/LauncherView.lua) +-- and the save editor (tools/save-editor/). This replaces the old navy +-- gradient look wholesale: black field, white hairline outlines, flat fills, +-- no gradients and no glows anywhere. +-- +-- That is not only a visual choice. Every effect this theme drops was a GPU +-- pipeline flush in the old renderer: +-- * gradients needed a stencil pass + a dynamic mesh per card +-- (G.stencil / setStencilTest / draw(mesh) = 3 state changes per card), +-- * glows set blend mode "add", drew 7 stacked rects, then set it back. +-- Flat fills with a 1px outline all share one pipeline state, so LOVE batches +-- an entire panel into a couple of draw calls. Controls do carry a small +-- corner radius and a two-rect emboss, which cost extra vertices but no state +-- change -- that is the tier of expense this theme is willing to pay, and the +-- tier above it (stencils, meshes, blend modes) is the one it will not. +-- +-- Emphasis is carried by INVERSION, not by colour weight: a selected or +-- focused control fills white and prints black. That keeps contrast at +-- maximum for accessibility and costs exactly one extra rect. +-- +-- Every colour below is 0-255 RGB; alpha is passed per draw call to col(). +-- Everything degrades under the headless love_stub used by tests/ (no fonts, +-- no line, no mesh): each primitive probes for what it needs. + +local Theme = {} + +local PAL = { + -- field + surfaces. Only three fills exist in the whole UI. + bg = { 0, 0, 0 }, -- the page, and every card interior + surface = { 0, 0, 0 }, -- cards/rows: same black, told apart by outline + raised = { 20, 20, 20 }, -- the one non-black fill: hover feedback + ink = { 255, 255, 255 }, -- the selected/focused fill + -- outlines. Two weights only: a hairline for structure, solid for focus. + line = { 255, 255, 255 }, -- hairline, drawn at alpha 0.35 + lineStrong = { 255, 255, 255 }, -- focus / selection, drawn at alpha 1 + -- text + heading = { 255, 255, 255 }, + text = { 255, 255, 255 }, + detail = { 200, 200, 200 }, + muted = { 150, 150, 150 }, + caption = { 170, 170, 170 }, -- letterspaced section captions + faint = { 110, 110, 110 }, -- slot indices, hints + inverse = { 0, 0, 0 }, -- ink on a white (selected/focused) fill + -- semantics. Used for TEXT and OUTLINES only, never as a large fill, so + -- the black/white contrast story is never diluted. + green = { 0, 255, 140 }, -- safe / confirmed / installed + yellow = { 255, 214, 0 }, -- attention / update available + red = { 255, 80, 90 }, -- destructive + blue = { 90, 190, 255 }, -- links, in-panel navigation + steel = { 120, 120, 120 }, -- disabled + -- the tri-colour version rail is the one piece of brand colour that stays + railRed = { 255, 60, 72 }, + railBlue = { 70, 150, 255 }, + railGold = { 255, 203, 5 }, +} +-- Semantic aliases kept so ported call sites read the same as before. +PAL.cardBorder = PAL.line +PAL.rowBg = PAL.surface +PAL.greenInk = PAL.inverse +PAL.blueInk = PAL.blue +PAL.redSoft = PAL.red +PAL.greenDark = PAL.green +Theme.PAL = PAL + +-- Standard alphas, so "hairline" means one thing everywhere. +Theme.A = { + hairline = 0.35, + hover = 0.65, + focus = 1.0, + fillHover= 1.0, + disabled = 0.30, +} + +local G = love and love.graphics or nil + +local has = {} +local function probe(name) + if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end + return has[name] +end +Theme.probe = probe + +function Theme.col(c, a) + if not G then return end + G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) +end +local col = Theme.col + +function Theme.clamp(n, lo, hi) + if n < lo then return lo end + if n > hi then return hi end + return n +end +local clamp = Theme.clamp + +-- --------------------------------------------------------------- primitives +-- Square, flat, snapped to whole pixels. Snapping matters at 1px line width: +-- a rect on a half pixel renders as a 2px grey smear instead of a crisp white +-- hairline, which is the whole look. +local function snap(v) return math.floor(v + 0.5) end +Theme.snap = snap + +function Theme.fill(x, y, w, h, c, a) + if not G or w <= 0 or h <= 0 then return end + col(c or PAL.bg, a or 1) + G.rectangle("fill", snap(x), snap(y), snap(w), snap(h)) +end + +-- Corner radius for controls. Small and fixed: enough to read as a physical +-- key rather than a painted rectangle, small enough that the extra +-- tessellation is noise next to the rest of the frame. +function Theme.radius() + return 4 +end + +function Theme.fillRounded(x, y, w, h, c, a, r) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.radius() + col(c or PAL.bg, a or 1) + G.rectangle("fill", snap(x), snap(y), snap(w), snap(h), r, r) +end + +function Theme.strokeRounded(x, y, w, h, c, a, lw, r) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + r = r or Theme.radius() + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw, r, r) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- EMBOSS. A lit top edge and a shaded bottom edge inside the control, which +-- is what makes a flat fill read as a raised key. Two thin rects on top of +-- the fill -- no gradient mesh, no stencil, no blend-mode change, so it costs +-- the same pipeline state as everything around it. +function Theme.emboss(x, y, w, h, strength) + if not G or w <= 2 or h <= 2 then return end + strength = strength or 1 + local t = math.max(1, math.floor(h * 0.10)) + local r = Theme.radius() + -- highlight along the top + col(PAL.ink, 0.28 * strength) + G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t) + -- shadow along the bottom + col(PAL.bg, 0.30 * strength) + G.rectangle("fill", snap(x) + r, snap(y + h) - t - 1, snap(w) - 2 * r, t) +end + +-- Faux bold: the UI face ships in one weight, so a bold run is the same text +-- drawn a second time one pixel across. Callers do this only for button +-- labels, where the extra draw is bounded by the number of controls on +-- screen and the text is already a cached Text object. +Theme.BOLD_OFFSET = 1 + +-- A 1px outline drawn INSIDE the rect, so a bordered control never bleeds +-- into its neighbour's pixel and adjacent outlines never double up to 2px. +function Theme.stroke(x, y, w, h, c, a, lw) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- The design's only container: black interior, white hairline. `emphasis` +-- raises the outline to full white (used for the focused/active card). +function Theme.card(x, y, w, h, emphasis) + Theme.fill(x, y, w, h, PAL.bg, 1) + Theme.stroke(x, y, w, h, PAL.line, emphasis and Theme.A.focus or Theme.A.hairline, 1) +end + +-- A list row. Three states, each one rect plus one outline: +-- normal black fill, hairline +-- hover near-black fill, brighter hairline +-- selected WHITE fill (callers print ink = PAL.inverse over it) +function Theme.row(x, y, w, h, state) + if state == "selected" then + Theme.fill(x, y, w, h, PAL.ink, 1) + return PAL.inverse + end + Theme.fill(x, y, w, h, state == "hover" and PAL.raised or PAL.surface, 1) + Theme.stroke(x, y, w, h, PAL.line, + state == "hover" and Theme.A.hover or Theme.A.hairline, 1) + return PAL.text +end + +-- A percentage meter (HP, box fill, dex completion, import progress). +-- pct is 0-100. Outline + solid white fill, no rounding. +function Theme.meter(x, y, w, h, pct, c) + if not G then return end + Theme.stroke(x, y, w, h, PAL.line, Theme.A.hairline, 1) + local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1) + if fill > 0 then Theme.fill(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1) end +end + +-- The 4px tri-colour rail across the top of both windows: the only brand +-- colour on screen, and the one thing that says "this is the Gen 1 launcher". +function Theme.versionRail(x, y, w, h) + if not G then return end + local bars = { PAL.railRed, PAL.railBlue, PAL.railGold } + local seg = w / 3 + for i, c in ipairs(bars) do + Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1) + end +end + +-- ------------------------------------------------------------------- text +-- Letterspaced caption text. The UI font has no tracking control, so this +-- advances glyph by glyph; captions are short by construction. +-- Measuring never throws. Third-party strings (mod names from an index, +-- translated captions) reach these primitives unvalidated. +local function safeWidthOrZero(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or 0 +end + +-- Steps CODEPOINTS, not bytes: a translated caption (the JP strings) is +-- multi-byte, and printing half a sequence is a "UTF-8 decoding error" that +-- takes the frame down. +local function eachChar(text, fn) + local i = 1 + local n = #text + while i <= n do + local j = i + 1 + while j <= n do + local b = text:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + fn(text:sub(i, j - 1)) + i = j + end +end + +function Theme.spaced(font, text, x, y, spacing) + if not G or not font then return 0 end + local cx = x + eachChar(tostring(text), function(ch) + pcall(G.print, ch, cx, y) + cx = cx + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, cx - x - spacing) +end + +function Theme.spacedWidth(font, text, spacing) + if not font then return 0 end + local w = 0 + eachChar(tostring(text), function(ch) + w = w + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, w - spacing) +end + +-- UTF-8 stepping. Truncation MUST move whole codepoints: LOVE's Font:getWidth +-- raises "UTF-8 decoding error" on a string cut through a multi-byte sequence, +-- and a launcher listing mods with non-ASCII names (the JP index) hits that on +-- the first frame. A continuation byte is 10xxxxxx (0x80..0xBF). +local function prevCharStart(s, i) + -- largest j < i where s:byte(j) starts a codepoint + local j = i - 1 + while j > 1 do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j - 1 + end + return j +end + +local function nextCharStart(s, i) + local j = i + 1 + while j <= #s do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + return j +end + +-- Width that never throws on malformed input: a mod name can carry anything. +local function safeWidth(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or math.huge +end +Theme.safeWidth = safeWidth + +-- Clip text to a pixel width with a trailing ellipsis. Results are memoised +-- per (font, text, width) in Kit's measurement cache -- this function is the +-- single hottest string operation in a list-heavy frame, and it is O(n) in +-- glyphs with a getWidth call per step. +function Theme.ellipsize(font, text, maxW) + text = tostring(text or "") + if not font then return text end + -- A non-positive budget means "nothing fits", not "everything fits". + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local last = #text + 1 -- one past the end of the kept prefix + while last > 1 do + last = prevCharStart(text, last) + local head = text:sub(1, last - 1) + if safeWidth(font, head) + ew <= maxW then return head .. ell end + end + return ell +end + +-- Save paths truncate from the LEFT so the filename survives. +function Theme.ellipsizeLeft(font, text, maxW) + text = tostring(text or "") + if not font then return text end + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local i = 1 + while i <= #text do + i = nextCharStart(text, i) + local tail = text:sub(i) + if safeWidth(font, tail) + ew <= maxW then return ell .. tail end + end + return ell +end + +-- The background: a flat black clear. One call, no mesh, no fan, no +-- allocation -- the old radial field built a 66-vertex mesh EVERY frame. +function Theme.field() + if not G then return end + G.clear(0, 0, 0, 1) +end + +-- ------------------------------------------------------------------- fonts +-- Font set, rebuilt only when the scale changes. Sizes are integers by +-- construction: fractional sizes measure and render at different widths, +-- which is what made ported launcher text overrun its measured box. +function Theme.fonts(s) + if not probe("newFont") then return {} end + -- Every face goes through UiFont.attach, which hangs a kana/CJK fallback + -- off it. Without that a translated build renders the entire launcher as + -- tofu boxes -- LOVE's default face is Latin-only. + local UiFont + local okUi, mod = pcall(require, "src.render.UiFont") + if okUi then UiFont = mod end + local cache = {} + local function f(px) + local n = math.max(8, math.floor(px + 0.5)) + if not cache[n] then + local face = G.newFont(n) + if UiFont and UiFont.attach then + local ok, attached = pcall(UiFont.attach, face, n) + if ok and attached then face = attached end + end + cache[n] = face + end + return cache[n] + end + return { + scale = s, + wordmark = f(14 * s), + brand = f(11 * s), + chip = f(11 * s), + tile = f(13 * s), + tab = f(13 * s), + button = f(14 * s), + small = f(12 * s), + tiny = f(11 * s), + micro = f(10 * s), + caption = f(12 * s), + mono = f(12 * s), + monoRow = f(13 * s), + monoBig = f(18 * s), + title = f(24 * s), + headline = f(26 * s), + stat = f(19 * s), + } +end + +return Theme diff --git a/tests/engine/flexlove_wheel_scroll_dt0.lua b/tests/engine/flexlove_wheel_scroll_dt0.lua deleted file mode 100644 index 825146d6..00000000 --- a/tests/engine/flexlove_wheel_scroll_dt0.lua +++ /dev/null @@ -1,63 +0,0 @@ --- Wheel scrolling must survive the launcher's immediate-mode frame order: --- FlexLove.update(dt) runs in love.update, but beginFrame (in draw) resets --- the accumulated dt to 0 BEFORE endFrame updates the elements, so --- ScrollManager:update always receives dt = 0 there. The smooth-scroll --- interpolation must still advance (it once used a fixed per-frame --- fraction; the dt-aware blend has to treat dt <= 0 as one nominal 60 Hz --- step, or every launcher list stops responding to the wheel entirely). -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -love.window.getMode = function() return 1024, 768, { fullscreen = false } end -love.window.getDesktopDimensions = function() return 1920, 1080 end -love.mouse = love.mouse or {} -love.mouse.getPosition = function() return 100, 100 end -love.mouse.isDown = love.mouse.isDown or function() return false end -package.loaded["libs.flexlove.modules.UTF8"] = { - char = string.char, charpattern = ".", codepoint = string.byte, - len = string.len, offset = function(_, n) return n end, - codes = function(s) - local i = 0 - return function() i = i + 1; if i <= #s then return i, s:byte(i) end end - end, -} - -local T = require("tests.modkit") -local FlexLove = require("libs.flexlove.FlexLove") -FlexLove.init({ - immediateMode = true, - performanceMonitoring = false, - keyboardNavigation = false, -}) - --- One launcher-shaped frame: update BEFORE beginFrame, exactly like --- LauncherView.update / draw, so endFrame sees _accumulatedDt == 0. -local function frame(wheelAfterBuild) - FlexLove.update(1 / 60) - FlexLove.beginFrame() - local scroller = FlexLove.new({ - id = "scroller", x = 0, y = 0, width = 400, height = 300, - overflowY = "scroll", hideScrollbars = true, - smoothScrollEnabled = true, scrollSpeed = 60, - positioning = "flex", flexDirection = "vertical", - }) - for i = 1, 30 do - FlexLove.new({ parent = scroller, id = "row" .. i, width = 380, height = 40 }) - end - FlexLove.endFrame() - if wheelAfterBuild then FlexLove.wheelmoved(0, -1) end - local sm = scroller._scrollManager - return sm and sm._scrollY or nil, sm and sm._targetScrollY or nil -end - -for _ = 1, 3 do frame(false) end -local y0, t0 = frame(true) -T.eq(t0, 60, "one wheel notch targets one scrollSpeed step") -T.eq(y0, 0, "the notch lands on the frame after input, not the same one") - -local y = y0 -for _ = 1, 5 do y = frame(false) end -T.check(y ~= nil and y > 30, "smooth scroll advances past halfway within 5 dt=0 frames") -for _ = 1, 60 do y = frame(false) end -T.check(y ~= nil and math.abs(y - 60) < 1, "and converges on the target") - -T.finish("flexlove wheel scroll at dt=0") diff --git a/tests/engine/gate_strings_coverage.lua b/tests/engine/gate_strings_coverage.lua index e03402a3..b3828c71 100644 --- a/tests/engine/gate_strings_coverage.lua +++ b/tests/engine/gate_strings_coverage.lua @@ -41,6 +41,8 @@ local ALLOWED = { { pattern = '%.%. "\\f"', why = "page-join glue between two texts" }, { pattern = "error%(", why = "a developer error, never drawn" }, { pattern = "Logger%.", why = "a log line, never drawn" }, + { pattern = "io%.stderr", why = "a dev-harness diagnostic, never drawn " + .. "(POKEPORT_LAUNCHER_PROF's frame timings)" }, { pattern = '== "\\v"', why = "comparing against a marker, not printing it" }, { pattern = "txBuf", why = "newline-delimited wire framing, not text" }, } diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index eea416b1..3d9a8289 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -249,14 +249,24 @@ do "LauncherView exports applyNxPerfGuards") check(view:find("LauncherView.applyNxPerfGuards(imp)", 1, true) ~= nil, "ensureFlex calls applyNxPerfGuards") - check(view:find("FlexLove._Performance.enabled = false", 1, true) ~= nil, - "NX guard disables Performance.enabled") - check(view:find("mp.enabled = false", 1, true) ~= nil, - "NX guard disables memory profiling") - -- The guard used to be NX-only; the same perf-timer and GC hitches showed - -- up in desktop scrolling, so it now applies on every platform. - check(view:find("if not (imp and FlexLove.isReady()", 1, true) ~= nil, - "perf guard applies on every platform (not gated on imp.isNX)") + -- The perf guards these lines used to assert were FlexLove's: turning its + -- per-frame profiler off and softening its GC strategy, because the + -- immediate-mode tree rebuild allocated a full element graph every frame. + -- FlexLove is gone; the kit has no profiler and no GC strategy to tune + -- because it does not allocate per frame. What is worth guarding now is + -- that the dependency does not come back -- on NX it was the single + -- largest frame cost in the launcher. + -- Test the DEPENDENCY, not the word: the file's header comment explains + -- what it replaced, and that history is worth keeping. + check(view:find("libs.flexlove", 1, true) == nil, + "the launcher view does not require FlexLove") + check(view:find("FlexLove%.%a") == nil, + "the launcher view makes no FlexLove calls") + check(view:find('require("src.ui.kit.Kit")', 1, true) ~= nil, + "the launcher view draws with the shared UI kit") + local hasLib = io.open("libs/flexlove/FlexLove.lua", "r") + if hasLib then hasLib:close() end + check(hasLib == nil, "the FlexLove library is not vendored any more") check(view:find("parkNxPointerForHost", 1, true) ~= nil, "detach parks NX pointer before tearing down") @@ -284,8 +294,13 @@ do check(view:find("math.floor(x + 0.5)", 1, true) ~= nil, "NX pad cursor draw is pixel-snapped") - check(view:find('strategy = "periodic"', 1, true) ~= nil, - "NX softens FlexLove GC strategy") + -- Pagination replaced scrolling, which is what removes the per-frame cost + -- that the old GC tuning was compensating for: a page's row count is + -- bounded by the viewport, so a long list costs what a short one does. + check(view:find("Kit.pager(", 1, true) ~= nil, + "launcher lists paginate instead of scrolling") + check(view:find("Kit.rowsThatFit(", 1, true) ~= nil, + "page size is derived from the real viewport height") end T.finish("launcher_nx_pad_cursor") diff --git a/tests/engine/launcher_save_slot_overlap_bug748.lua b/tests/engine/launcher_save_slot_overlap_bug748.lua deleted file mode 100644 index 2be74fe3..00000000 --- a/tests/engine/launcher_save_slot_overlap_bug748.lua +++ /dev/null @@ -1,173 +0,0 @@ --- Regression for #748: FlexLove propagates a nested child's auto-height --- change only to its direct parent while the launcher tree is constructed. --- The two-column grid can therefore retain the shorter left-column height --- after the save-slot card makes the right column taller, placing the footer --- over the bottom of that card. Keep this test ROM- and renderer-free. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end - --- Element sizing asks the window for its current mode. FlexLove also loads --- UTF8 helpers for rendering, although this geometry test draws no text. -love.window.getMode = function() - return 1024, 768, { fullscreen = false } -end -love.window.getDesktopDimensions = function() return 1920, 1080 end -package.loaded["libs.flexlove.modules.UTF8"] = { - char = string.char, - charpattern = ".", - codepoint = string.byte, - len = string.len, - offset = function(_, n) return n end, - codes = function(s) - local i = 0 - return function() - i = i + 1 - if i <= #s then return i, s:byte(i) end - end - end, -} - -local T = require("tests.modkit") -local FlexLove = require("libs.flexlove.FlexLove") -FlexLove.init({ - immediateMode = false, - performanceMonitoring = false, - keyboardNavigation = false, -}) -local LauncherView = require("src.import.LauncherView") -local refreshAutoHeight = LauncherView._refreshAutoHeight - --- Preserve the launcher's construction order. The left card grows first; --- adding the right column then refreshes the grid to 220. Growing a card --- nested inside that right column reaches the column, but not the grid. -local function nestedColumns(leftHeight, rightHeight, gridOverrides) - local page = FlexLove.new({ - width = 800, - positioning = "flex", - flexDirection = "vertical", - gap = 12, - }) - local gridProps = { - parent = page, - width = 800, - positioning = "flex", - flexDirection = "horizontal", - alignItems = "flex-start", - } - for key, value in pairs(gridOverrides or {}) do - gridProps[key] = value - end - local grid = FlexLove.new(gridProps) - local left = FlexLove.new({ - parent = grid, - width = 390, - positioning = "flex", - flexDirection = "vertical", - }) - local leftCard = FlexLove.new({ - parent = left, - width = 390, - positioning = "flex", - flexDirection = "vertical", - }) - FlexLove.new({ parent = leftCard, width = 390, height = leftHeight }) - local right = FlexLove.new({ - parent = grid, - width = 390, - positioning = "flex", - flexDirection = "vertical", - }) - local rightCard = FlexLove.new({ - parent = right, - width = 390, - positioning = "flex", - flexDirection = "vertical", - }) - FlexLove.new({ parent = rightCard, width = 390, height = rightHeight }) - return { page = page, grid = grid, left = left, right = right } -end - --- Reported shape: real FlexLove elements finish with a 520px right column, --- but both the grid and page still reserve only the 220px left extent. -local tallRight = nestedColumns(220, 520) -local gap = 12 -T.eq(tallRight.left:getBorderBoxHeight(), 220, - "the left launcher column finishes at its content height") -T.eq(tallRight.right:getBorderBoxHeight(), 520, - "the nested save-slot column grows to its completed height") -T.eq(tallRight.grid:getBorderBoxHeight(), 220, - "FlexLove leaves the outer two-column grid stale") -T.eq(tallRight.page:getBorderBoxHeight(), 220, - "the page inherits the stale grid extent") -T.eq(tallRight.grid:calculateAutoHeight(), 520, - "the completed grid can measure the correct taller extent") -T.check(tallRight.grid:getBorderBoxHeight() + gap - < tallRight.right:getBorderBoxHeight(), - "the stale grid places the following footer over the save-slot column") - -if not T.check(type(refreshAutoHeight) == "function", - "launcher exports the auto-height reconciliation seam") then - T.finish("launcher save-slot overlap #748") -end - -tallRight.grid._dirty = false -tallRight.page._childrenDirty = false -local refreshedHeight = refreshAutoHeight(tallRight.grid) -T.eq(refreshedHeight, 520, "reconciliation returns the taller border-box height") -T.eq(tallRight.grid.height, 520, "reconciliation updates content height") -T.eq(tallRight.grid:getBorderBoxHeight(), 520, - "reconciliation updates the cached border-box height") -T.check(tallRight.grid._dirty, "reconciliation invalidates the grid") -T.check(tallRight.page._childrenDirty, - "reconciliation invalidates ancestor layout") -T.check(tallRight.grid:getBorderBoxHeight() + gap - >= tallRight.right:getBorderBoxHeight(), - "the following footer starts below the completed save-slot column") - --- Reconciliation uses the maximum completed column; it must not blindly --- copy the right side and shrink an already-taller left side. -local shortRight = nestedColumns(420, 220) -T.eq(refreshAutoHeight(shortRight.grid), 420, - "a shorter right column preserves the taller left extent") -T.eq(shortRight.grid.height, 420, - "short content keeps its correct content height") -T.eq(shortRight.grid:getBorderBoxHeight(), 420, - "short content keeps its correct border-box height") - --- Match FlexLove's resize path: clamp the padded border box, then derive and --- clamp the content box from it. -local constrained = nestedColumns(600, 500, { - padding = { top = 10, bottom = 20 }, - maxHeight = 550, -}) -T.eq(constrained.grid:calculateAutoHeight(), 600, - "the constrained grid measures its taller child before padding") -T.eq(refreshAutoHeight(constrained.grid), 550, - "max-height clamps the padded border box") -T.eq(constrained.grid.height, 520, - "content height subtracts padding from the constrained border box") -T.eq(constrained.grid:getBorderBoxHeight(), 550, - "the constrained border-box cache stays synchronized") - --- Invalid or inapplicable measurements are fail-closed and leave geometry --- untouched rather than poisoning the next layout pass. -local notANumber = nestedColumns(220, 520) -notANumber.grid.calculateAutoHeight = function() return 0 / 0 end -notANumber.grid._dirty = false -T.eq(refreshAutoHeight(notANumber.grid), false, "NaN auto height is rejected") -T.eq(notANumber.grid.height, 220, "NaN leaves content height unchanged") -T.eq(notANumber.grid:getBorderBoxHeight(), 220, - "NaN leaves border-box height unchanged") -T.eq(notANumber.grid._dirty, false, "NaN does not invalidate layout") - -local infinite = nestedColumns(220, 520) -infinite.grid.calculateAutoHeight = function() return math.huge end -T.eq(refreshAutoHeight(infinite.grid), false, "infinite auto height is rejected") -T.eq(infinite.grid.height, 220, "infinite height leaves geometry unchanged") - -local fixed = FlexLove.new({ width = 800, height = 220 }) -T.eq(refreshAutoHeight(fixed), false, "fixed-height elements are ignored") -T.eq(fixed.height, 220, "fixed-height geometry is unchanged") -T.eq(refreshAutoHeight(nil), false, "a missing element is ignored") - -T.finish("launcher save-slot overlap #748") diff --git a/tests/engine/ui_kit_pagination.lua b/tests/engine/ui_kit_pagination.lua new file mode 100644 index 00000000..d18bbf00 --- /dev/null +++ b/tests/engine/ui_kit_pagination.lua @@ -0,0 +1,127 @@ +-- The launcher's UI kit: pagination bounds, viewport-derived page size, and +-- the text safety rules. These replace the two FlexLove engine tests +-- (flexlove_wheel_scroll_dt0, launcher_save_slot_overlap_bug748), whose +-- subjects -- a scroll manager fed dt = 0 and an auto-height propagation bug +-- -- no longer exist: there is no scrolling and no layout engine. +-- +-- What DOES need guarding now is the property the whole performance claim +-- rests on: a list draws at most one page of rows regardless of how many +-- items it holds, and the page arithmetic never walks off either end. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") + +-- ---------------------------------------------------------------- page math +-- pageBounds(page, total, perPage) -> first, last, clampedPage, pages +do + local first, last, page, pages = Kit.pageBounds(1, 38, 5) + T.eq(first, 1, "page 1 starts at item 1") + T.eq(last, 5, "page 1 ends at perPage") + T.eq(page, 1, "page 1 is in range") + T.eq(pages, 8, "38 items at 5 per page is 8 pages") + + first, last = Kit.pageBounds(8, 38, 5) + T.eq(first, 36, "the last page starts after the full ones") + T.eq(last, 38, "the last page stops at the item count, not at perPage") + + -- Out of range in both directions clamps rather than producing a window + -- that would index past the list (the launcher repages lists underneath + -- the user: a refresh can shrink an index from 38 mods to 2). + local _, _, low = Kit.pageBounds(0, 38, 5) + T.eq(low, 1, "page 0 clamps up to 1") + local f2, l2, high, p2 = Kit.pageBounds(99, 38, 5) + T.eq(high, 8, "a page past the end clamps to the last page") + T.eq(p2, 8, "the page count is unchanged by clamping") + T.check(l2 >= f2, "a clamped window is never inverted") + + -- An empty list still has one page, and draws no rows. + local ef, el, ep, epages = Kit.pageBounds(1, 0, 5) + T.eq(epages, 1, "an empty list still reports one page") + T.eq(ep, 1, "an empty list sits on page 1") + T.check(el < ef, "an empty page yields an empty row range") + + -- THE INVARIANT: no page ever yields more rows than perPage. + for total = 0, 40 do + for per = 1, 7 do + local _, _, _, np = Kit.pageBounds(1, total, per) + for p = 1, np do + local a, b = Kit.pageBounds(p, total, per) + T.check(b - a + 1 <= per, + ("page %d of %d (total %d) draws at most %d rows"):format(p, np, total, per)) + T.check(b <= total, "a page never runs past the item count") + end + end + end +end + +-- ------------------------------------------------------- viewport page size +-- rowsThatFit derives perPage from the real viewport, which is what lets a +-- tall window show more rows and a phone fewer with no scrolling either way. +do + -- n rows need n*rowH + (n-1)*gap pixels. + T.eq(Kit.rowsThatFit(200, 80, 20, 1, 20), 2, "200px fits two 80px rows + one gap") + T.eq(Kit.rowsThatFit(180, 80, 20, 1, 20), 2, "exactly two rows still fit") + T.eq(Kit.rowsThatFit(179, 80, 20, 1, 20), 1, "one pixel short drops to one row") + T.eq(Kit.rowsThatFit(0, 80, 20, 1, 20), 1, + "a collapsed viewport still shows one row rather than none") + T.eq(Kit.rowsThatFit(-500, 80, 20, 1, 20), 1, + "a negative budget cannot produce a negative page size") + T.eq(Kit.rowsThatFit(100000, 80, 20, 1, 12), 12, "the cap is honoured") +end + +-- ------------------------------------------------------------- text safety +-- Truncation must move whole codepoints. LOVE's Font:getWidth raises +-- "UTF-8 decoding error" on a string cut through a multi-byte sequence, and +-- the launcher lists mod names from third-party indexes -- this crashed the +-- first frame on a Japanese listing. +do + -- A font stub that REFUSES malformed UTF-8, the way LOVE's does. + local font = {} + function font:getWidth(s) + local i = 1 + while i <= #s do + local b = s:byte(i) + local n = (b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) + or (b >= 0xC0 and 2) or nil + if not n then error("UTF-8 decoding error: unexpected continuation", 0) end + for k = 1, n - 1 do + local c = s:byte(i + k) + if not c or c < 0x80 or c >= 0xC0 then + error("UTF-8 decoding error: Not enough space", 0) + end + end + i = i + n + end + -- 10px per codepoint, whatever its byte length + local count = 0 + i = 1 + while i <= #s do + local b = s:byte(i) + i = i + ((b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or 2) + count = count + 1 + end + return count * 10 + end + + local jp = "ポケットモンスター" -- 9 codepoints, 27 bytes + local ok, cut = pcall(Theme.ellipsize, font, jp, 55) + T.check(ok, "ellipsize does not raise on multi-byte text: " .. tostring(cut)) + T.check(font:getWidth(cut) <= 55 + 1e-9, "the result fits the budget") + local ok2 = pcall(font.getWidth, font, cut) + T.check(ok2, "the truncated string is still valid UTF-8") + + local okL, cutL = pcall(Theme.ellipsizeLeft, font, jp, 55) + T.check(okL, "ellipsizeLeft does not raise on multi-byte text") + T.check(pcall(font.getWidth, font, cutL), + "the left-truncated string is still valid UTF-8") + + T.eq(Theme.ellipsize(font, jp, 0), "", + "a zero budget means nothing fits, not everything fits") + T.eq(Theme.ellipsize(font, "abc", 999), "abc", + "text that already fits is returned untouched") +end + +T.finish("ui_kit_pagination") diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 7173ab94..5d421f8e 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -36,6 +36,7 @@ local MapBrowser = require("MapBrowser") local Dex = require("Dex") -- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) local SpeciesPicker = require("SpeciesPicker") +local ItemPicker = require("ItemPicker") local App = {} local S @@ -341,6 +342,37 @@ function App.update(dt) if notches ~= 0 then App.wheelmoved(0, notches) end + + -- Dev harness, the launcher's POKEPORT_LAUNCHER_SHOT for this window: + -- POKEPORT_EDITOR_SHOT=/path.png with POKEPORT_WIN=WxH resizes, lets the + -- view settle, captures one frame and quits, so a scripted run can see the + -- real editor at any window shape. POKEPORT_EDITOR_TAB picks the tab and + -- POKEPORT_EDITOR_ITEMPICK=1 opens the add-item modal. + local shot = os.getenv("POKEPORT_EDITOR_SHOT") + if shot and not App._shotDone then + if not App._shotSized then + App._shotSized = true + local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$") + if w and love.window and love.window.setMode then + pcall(love.window.setMode, tonumber(w), tonumber(h), { resizable = true }) + end + local tab = os.getenv("POKEPORT_EDITOR_TAB") + if tab and tab ~= "" and S then S.tab = tab end + if os.getenv("POKEPORT_EDITOR_ITEMPICK") == "1" and S then + Ops.openItemPicker(S, Kit, "bag") + end + end + App._shotTimer = (App._shotTimer or 0) + dt + if App._shotTimer > 1.0 then + App._shotDone = true + love.graphics.captureScreenshot(function(imagedata) + local fd = imagedata:encode("png") + local f = io.open(shot, "wb") + if f then f:write(fd:getString()) f:close() end + love.event.quit() + end) + end + end end function App.mousepressed(x, y, button) @@ -750,7 +782,7 @@ function App.draw() -- last: the chrome and the panel underneath would take the same tap. The -- shield goes up before anything dispatches and comes down only for the -- picker's own layer at the bottom of this function (#541). - Kit.blockClicks = (S.speciesPicker ~= nil) + Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil) Theme.field(width, height) @@ -779,6 +811,7 @@ function App.draw() drawStatusBar(0, height - statusH, width, statusH) Kit.blockClicks = false SpeciesPicker.draw(S, Kit, width, height) + ItemPicker.draw(S, Kit, width, height) Kit.endFrame() PadInput.draw() @@ -791,6 +824,15 @@ function App.keypressed(key) -- The picker takes Enter and Escape before the focused field does: Kit maps -- both to the same "\r" edit (a blur), which cannot tell "commit the top -- match" apart from "give up" (#541). + if S.itemPicker then + if key == "return" or key == "kpenter" then + ItemPicker.commitFirst(S, Kit) + return + elseif key == "escape" then + Ops.closeItemPicker(S, Kit) + return + end + end if S.speciesPicker then if key == "return" or key == "kpenter" then SpeciesPicker.commitFirst(S, Kit) diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index d542ed54..4c40d920 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -309,20 +309,18 @@ end -- good green tint -- safe helpers (Full heal, max a DV) -- danger red tint -- destructive verbs, always two-click -- disabled steel -- never hidden, always explained in the status bar +-- Colour-coded solid keys, matching the launcher exactly (src/ui/kit/Kit.lua): +-- the button IS its colour, with black ink and a two-rect emboss, and hover +-- rings it in white. The editor opens straight off a launcher save row, so a +-- control that behaves the same has to look the same. local KINDS = { - primary = { fillTop = PAL.green, fillBot = PAL.greenDark, aTop = 1, aBot = 1, - ink = PAL.greenInk, border = nil, glow = PAL.green }, - ghost = { fillTop = { 255, 255, 255 }, fillBot = { 255, 255, 255 }, - aTop = 0.14, aBot = 0.03, ink = PAL.heading, - border = { 255, 255, 255 }, borderA = 0.18 }, - accent = { flat = PAL.blue, flatA = 0.14, ink = PAL.blueInk, - border = PAL.cardBorder, borderA = 0.35 }, - good = { flat = PAL.green, flatA = 0.1, ink = PAL.green, - border = PAL.green, borderA = 0.45 }, - danger = { flat = PAL.red, flatA = 0.12, ink = PAL.redSoft, - border = PAL.red, borderA = 0.45 }, - disabled = { flat = { 120, 132, 158 }, flatA = 0.22, ink = PAL.steel, - border = PAL.steel, borderA = 0.3 }, + primary = { fill = PAL.green, ink = PAL.inverse }, + good = { fill = PAL.green, ink = PAL.inverse }, + accent = { fill = PAL.blue, ink = PAL.inverse }, + warn = { fill = PAL.yellow, ink = PAL.inverse }, + danger = { fill = PAL.red, ink = PAL.inverse }, + ghost = { fill = PAL.ink, ink = PAL.inverse }, + disabled = { fill = PAL.steel, ink = PAL.inverse }, } -- opts: { kind, font, enabled, align, radius, glow } @@ -338,31 +336,30 @@ function Kit.button(x, y, w, h, label, opts) local hot = enabled and Kit.hover(x, y, w, h) if G then - if opts.glow and enabled then - Theme.glow(x, y, w, h, r, kind.glow or PAL.green, opts.glow) - end - if kind.flat then - Theme.col(kind.flat, kind.flatA * (hot and 1.6 or 1)) - G.rectangle("fill", x, y, w, h, r, r) - else - Theme.gradRounded(x, y, w, h, r, kind.fillTop, kind.fillBot, - kind.aTop * (hot and 1.4 or 1), kind.aBot * (hot and 1.6 or 1)) - end - if kind.border then - Theme.stroke(x, y, w, h, r, kind.border, kind.borderA * (hot and 1.5 or 1), 1) + Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) + Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) + if hot then + Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, 1, 2, + Theme.radius() + 2) end local f = font(opts.font or "button") if f then G.setFont(f) Theme.col(kind.ink, 1) local ty = y + (h - f:getHeight()) / 2 - if opts.align == "left" then - G.print(label, x + 10 * Kit.scale, ty) - elseif canPrintf() then - G.printf(label, x, ty, w, "center") - else - G.print(label, x + (w - f:getWidth(label)) / 2, ty) + -- Faux bold, the same trick the launcher uses: one weight of face, so + -- an emphasised run is the same text drawn a pixel across. + local function put(dx) + if opts.align == "left" then + G.print(label, x + 10 * Kit.scale + dx, ty) + elseif canPrintf() then + G.printf(label, x + dx, ty, w, "center") + else + G.print(label, x + (w - f:getWidth(label)) / 2 + dx, ty) + end end + put(0) + put(Theme.BOLD_OFFSET or 1) end end return enabled and Kit.press(x, y, w, h) or false @@ -383,12 +380,21 @@ function Kit.chip(x, y, w, h, label, on, onColor, offColor) audit("control", x, y, w, h, label) local c = on and (onColor or PAL.green) or (offColor or PAL.steel) if G then - local r = 6 * Kit.scale - Theme.col(c, on and 0.16 or 0.06) - G.rectangle("fill", x, y, w, h, r, r) - Theme.stroke(x, y, w, h, r, PAL.cardBorder, Kit.hover(x, y, w, h) and 0.5 or 0.28, 1) - Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w, - c, on and 1 or 0.75) + -- Same rule as the launcher's chips: an ON chip is FILLED with its colour + -- and prints black; an OFF chip is an outline. The old alpha-tinted fill + -- read as "slightly different dark rectangle" on a black field. + local hot = Kit.hover(x, y, w, h) + if on then + Theme.fillRounded(x, y, w, h, c, 1) + Theme.emboss(x, y, w, h, 1) + Kit.textCenter("micro", label, x, + y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse) + else + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, c, hot and 1 or 0.5, 1) + Kit.textCenter("micro", label, x, + y + (h - Kit.textHeight("micro")) / 2, w, c) + end end return Kit.press(x, y, w, h) end diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index fb367806..78d72092 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -266,6 +266,36 @@ function Ops.openSpeciesPicker(S, Kit) return true end +-- The item catalog minus the badges, which are toggles on their own row and +-- would otherwise be "addable" into the bag as ordinary items. +function Ops.itemSearch(S, query) + query = tostring(query or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + local out = {} + for _, id in ipairs(S.cat.items) do + if not Ops.isBadgeId(id) + and (query == "" or id:lower():find(query, 1, true)) then + out[#out + 1] = id + end + end + return out +end + +-- `dest` is "bag" or "pc"; the picker can flip it while open. `opened` +-- marks the frame it went up, so the click that opened it is not also read +-- as a tap outside (the same rule the species picker follows). +function Ops.openItemPicker(S, Kit, dest) + S.itemPicker = { query = "", offset = 0, opened = true, + dest = dest or "bag" } + -- focus the field on open so the mobile soft keyboard rises with it (#529) + if Kit then Kit.focus = "item-picker" end + return true +end + +function Ops.closeItemPicker(S, Kit) + S.itemPicker = nil + if Kit and Kit.blur then Kit.blur() end +end + function Ops.closeSpeciesPicker(S, Kit) S.speciesPicker = nil if Kit and Kit.blur then Kit.blur() end diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 61f992db..47742c78 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -50,6 +50,12 @@ function State.new() -- without a z-order (#541). speciesPicker = nil, + -- item picker overlay: nil when closed, otherwise + -- { query, offset, dest = "bag"|"pc" }. Same modal contract as + -- speciesPicker above -- adding an item is now a full-screen picker + -- rather than a card competing for height inside the Items tab. + itemPicker = nil, + -- boxes selectedBox = 1, selectedBoxSlot = 1, diff --git a/tools/save-editor/Theme.lua b/tools/save-editor/Theme.lua index 94cdc30e..0deeec13 100644 --- a/tools/save-editor/Theme.lua +++ b/tools/save-editor/Theme.lua @@ -1,333 +1,89 @@ --- Shared look for the save editor: the launcher's palette and its drawing --- primitives, lifted out so the editor and src/import/RomImporter.lua render --- the same navy field, the same 16px translucent cards and the same neon --- accents. The editor is reachable straight off a launcher save row (Edit), --- so the two windows have to read as one app -- see SaveEditor.dc.html, which --- is the design spec these literals come from. +-- The save editor's look, delegated to the shared high-contrast theme +-- (src/ui/kit/Theme.lua). -- --- Every colour below is 0-255 RGB; alpha is passed per draw call to col(). +-- The editor is reachable straight off a launcher save row (Edit), so the two +-- windows have to read as one app. They used to do that by keeping two +-- copies of the same navy palette in sync by hand; now there is one palette +-- and this file is the adapter. Everything below is either a re-export or a +-- shim for a primitive the old navy design had and the flat one does not: -- --- Everything here degrades when a love.graphics entry point is missing: the --- headless love_stub used by tests/ has no fonts, stencil, mesh or line, so --- each primitive checks for its dependency and falls back to a flat fill (or --- nothing) rather than erroring. That keeps App.draw callable under the stub. +-- gradRounded -> flat fill of the bottom colour (there are no gradients) +-- glow -> nothing (there are no glows) +-- dashed -> a plain hairline (the dashed path sampled a rounded +-- outline into a polyline every frame for an empty-state +-- box, which is a lot of work to say "nothing here") +-- +-- Keeping the old signatures means the editor's panels did not have to be +-- rewritten to change theme, and a panel that has not been revisited yet +-- still lands on the new palette instead of drawing navy into a black window. + +local Shared = require("src.ui.kit.Theme") local Theme = {} -local PAL = { - -- radial background field: bright navy at top-centre -> near black - bgTop = { 22, 34, 74 }, -- #16224a - bgMid = { 12, 19, 48 }, -- #0c1330 - bgBot = { 7, 11, 29 }, -- #070b1d - -- panel + row surfaces - cardTint = { 70, 150, 255 }, -- rgba(70,150,255,0.08) card top light - cardBody = { 12, 18, 40 }, -- rgba(12,18,40,0.5) card interior - cardBorder = { 120, 150, 220 }, -- rgba(120,150,220,0.28) hairline - rowBg = { 9, 14, 34 }, -- rgba(9,14,34,0.60) row interior - -- text - heading = { 255, 255, 255 }, - text = { 223, 230, 245 }, -- #dfe6f5 - detail = { 198, 208, 230 }, -- #c6d0e6 - muted = { 159, 176, 208 }, -- #9fb0d0 - caption = { 143, 163, 200 }, -- #8fa3c8 letterspaced section captions - faint = { 111, 130, 168 }, -- #6f82a8 slot indices, hints - -- semantics: green = safe/confirmed, yellow = attention, red = destructive - green = { 62, 224, 138 }, -- #3ee08a - greenDark = { 22, 163, 90 }, -- #16a35a - greenInk = { 6, 32, 18 }, -- #062012 - yellow = { 255, 203, 5 }, -- #ffcb05 - red = { 255, 92, 103 }, -- #ff5c67 - redSoft = { 255, 143, 150 }, -- #ff8f96 destructive button ink - blue = { 70, 150, 255 }, -- #4696ff - blueInk = { 207, 224, 255 }, -- #cfe0ff ink on blue-tinted controls - steel = { 149, 161, 189 }, -- #95a1bd disabled - -- the tri-colour version rail, identical to the launcher's - railRed = { 255, 60, 72 }, - railBlue = { 70, 150, 255 }, - railGold = { 255, 203, 5 }, - -- chip / tab tile gradient (the launcher's mod chip) - chipTop = { 61, 74, 109 }, -- #3d4a6d - chipBot = { 32, 42, 69 }, -- #202a45 - chipInk = { 207, 224, 255 }, -- #cfe0ff -} +-- The palette itself, plus the aliases the editor's panels already use. +local PAL = {} +for k, v in pairs(Shared.PAL) do PAL[k] = v end +-- Names the editor uses that the shared palette spells differently. +PAL.cardTint = PAL.bg +PAL.cardBody = PAL.bg +PAL.chipTop = PAL.bg +PAL.chipBot = PAL.bg +PAL.chipInk = PAL.text +PAL.bgTop = PAL.bg +PAL.bgMid = PAL.bg +PAL.bgBot = PAL.bg Theme.PAL = PAL -local G = love and love.graphics or nil - --- Feature probes: the headless stub implements only a handful of these. -local has = {} -local function probe(name) - if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end - return has[name] -end - -function Theme.col(c, a) - if not G then return end - G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) -end -local col = Theme.col - -function Theme.clamp(n, lo, hi) - if n < lo then return lo end - if n > hi then return hi end - return n -end -local clamp = Theme.clamp - --- ---------------------------------------------------------------- gradients --- One reusable unit-square mesh whose four corner colours are rewritten per --- call, so a vertical gradient costs a single draw (same trick the launcher --- uses). Nil under the stub, where every gradient degrades to a flat fill. -local gradMesh -local function setGrad(cTop, cBot, aTop, aBot) - if not probe("newMesh") then return false end - if not gradMesh then - gradMesh = G.newMesh({ - { 0, 0, 0, 0, 1, 1, 1, 1 }, - { 1, 0, 1, 0, 1, 1, 1, 1 }, - { 1, 1, 1, 1, 1, 1, 1, 1 }, - { 0, 1, 0, 1, 1, 1, 1, 1 }, - }, "fan", "dynamic") - end - local t = { cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop } - local b = { cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot } - gradMesh:setVertexAttribute(1, 3, t[1], t[2], t[3], t[4]) - gradMesh:setVertexAttribute(2, 3, t[1], t[2], t[3], t[4]) - gradMesh:setVertexAttribute(3, 3, b[1], b[2], b[3], b[4]) - gradMesh:setVertexAttribute(4, 3, b[1], b[2], b[3], b[4]) - return true -end - --- Vertical gradient clipped to a rounded rect. Falls back to a flat fill of --- the bottom colour when the stencil buffer or meshes are unavailable. -function Theme.gradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot) - if not G then return end - if w <= 0 or h <= 0 then return end - if not (probe("stencil") and probe("setStencilTest") and setGrad(cTop, cBot, aTop, aBot)) then - col(cBot, aBot) - G.rectangle("fill", x, y, w, h, r, r) - return - end - G.stencil(function() G.rectangle("fill", x, y, w, h, r, r) end, "replace", 1) - G.setStencilTest("greater", 0) - G.setColor(1, 1, 1, 1) - G.draw(gradMesh, x, y, 0, w, h) - G.setStencilTest() -end - --- The design's standard content panel: a faint top-lit blue tint fading into --- a dark interior behind a 1px cool-gray hairline. Every card in the editor --- (and every card in the launcher) is this shape. -function Theme.card(x, y, w, h, r) - if not G then return end - r = r or 16 - Theme.gradRounded(x, y, w, h, r, PAL.cardTint, PAL.cardBody, 0.08, 0.5) - Theme.stroke(x, y, w, h, r, PAL.cardBorder, 0.28, 1) -end - --- A list row / inner surface: flat dark fill, fainter hairline than a card. -function Theme.row(x, y, w, h, r, alpha) - if not G then return end - col(PAL.rowBg, alpha or 0.6) - G.rectangle("fill", x, y, w, h, r or 12, r or 12) - Theme.stroke(x, y, w, h, r or 12, PAL.cardBorder, 0.22, 1) -end - +Theme.col = Shared.col +Theme.clamp = Shared.clamp +Theme.snap = Shared.snap +-- The editor's stroke carries a corner radius as its 5th argument (the +-- shared one takes the colour there). Route it to the rounded variant so +-- the radius the panels already pass is honoured rather than dropped. function Theme.stroke(x, y, w, h, r, c, a, lw) - if not G then return end - if probe("setLineWidth") then G.setLineWidth(math.max(1, lw or 1)) end - col(c, a or 1) - G.rectangle("line", x, y, w, h, r or 0, r or 0) - if probe("setLineWidth") then G.setLineWidth(1) end + Shared.strokeRounded(x, y, w, h, c, a, lw, math.min(r or 0, 8)) +end +Theme.spaced = Shared.spaced +Theme.spacedWidth = Shared.spacedWidth +Theme.ellipsize = Shared.ellipsize +Theme.ellipsizeLeft = Shared.ellipsizeLeft +Theme.meter = Shared.meter +Theme.versionRail = Shared.versionRail +Theme.fonts = Shared.fonts +Theme.radius = Shared.radius +Theme.fillRounded = Shared.fillRounded +Theme.strokeRounded = Shared.strokeRounded +Theme.emboss = Shared.emboss +Theme.BOLD_OFFSET = Shared.BOLD_OFFSET + +-- The editor calls card/row with a trailing radius (and row with an alpha) +-- that the flat theme has no use for; accept and ignore them. +function Theme.card(x, y, w, h, _r) + Shared.card(x, y, w, h) end --- Soft additive halo around a rounded rect (LOVE has no blur, so stack --- progressively larger, fainter rects). Marks the selected party slot and --- the hot Save button. -function Theme.glow(x, y, w, h, r, c, strength) - if not G or not probe("setBlendMode") then return end - strength = math.max(0, strength or 0) - if strength == 0 then return end - G.setBlendMode("add") - local layers = 7 - for i = 1, layers do - local g = i * 2.2 - G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, - strength * 0.05 * (1 - (i - 1) / layers)) - G.rectangle("fill", x - g, y - g, w + 2 * g, h + 2 * g, r + g, r + g) - end - G.setBlendMode("alpha") +function Theme.row(x, y, w, h, _r, _alpha) + Shared.row(x, y, w, h) end --- Dashed rounded outline (LOVE has no dash pattern): sample the path into a --- polyline, then walk it toggling on/off. Used for empty-state boxes and the --- "add here" slots in the box grid. Caller sets colour + line width. -function Theme.dashed(x, y, w, h, r, dash, gap) - if not G or not probe("line") then return end - if w <= 0 or h <= 0 then return end - r = math.min(r, w / 2, h / 2) - local seg = 4 - local pts = {} - local function arc(cx, cy, a0, a1) - for i = 0, seg do - local a = a0 + (a1 - a0) * (i / seg) - pts[#pts + 1] = cx + math.cos(a) * r - pts[#pts + 1] = cy + math.sin(a) * r - end - end - arc(x + w - r, y + r, -math.pi / 2, 0) - arc(x + w - r, y + h - r, 0, math.pi / 2) - arc(x + r, y + h - r, math.pi / 2, math.pi) - arc(x + r, y + r, math.pi, math.pi * 1.5) - pts[#pts + 1] = pts[1]; pts[#pts + 1] = pts[2] - local remaining, drawing = dash, true - for i = 1, #pts - 2, 2 do - local x1, y1 = pts[i], pts[i + 1] - local dx, dy = pts[i + 2] - x1, pts[i + 3] - y1 - local segLen = math.sqrt(dx * dx + dy * dy) - local pos = 0 - while pos < segLen do - local step = math.min(remaining, segLen - pos) - if drawing then - local t0, t1 = pos / segLen, (pos + step) / segLen - G.line(x1 + dx * t0, y1 + dy * t0, x1 + dx * t1, y1 + dy * t1) - end - pos = pos + step - remaining = remaining - step - if remaining <= 0.0001 then - drawing = not drawing - remaining = drawing and dash or gap - end - end - end +-- Flat fill of the "bottom" colour: the old gradient's endpoint, so a control +-- that used to fade into it keeps roughly its old weight. +function Theme.gradRounded(x, y, w, h, _r, _cTop, cBot, _aTop, aBot) + Shared.fill(x, y, w, h, cBot, aBot) end --- Letterspaced text: the UI font has no tracking control, so advance glyph by --- glyph. Section captions are 12px/2px-tracked uppercase throughout. -function Theme.spaced(font, text, x, y, spacing) - if not G or not font then return 0 end - local cx = x - for i = 1, #text do - local ch = text:sub(i, i) - G.print(ch, cx, y) - cx = cx + font:getWidth(ch) + spacing - end - return math.max(0, cx - x - spacing) +-- No glows in this theme. Kept so call sites do not have to be edited, and +-- so nothing silently starts setting blend modes again. +function Theme.glow() end + +-- The empty-state box is a hairline now, not a dashed outline. +function Theme.dashed(x, y, w, h, _r, _dash, _gap) + Shared.stroke(x, y, w, h, PAL.line, 0.22, 1) end -function Theme.spacedWidth(font, text, spacing) - if not font then return 0 end - local w = 0 - for i = 1, #text do w = w + font:getWidth(text:sub(i, i)) + spacing end - return math.max(0, w - spacing) -end - --- Clip text to a pixel width with a trailing ellipsis. Save paths truncate --- from the LEFT instead (see Theme.ellipsizeLeft) so the filename survives. -function Theme.ellipsize(font, text, maxW) - text = tostring(text or "") - if not font then return text end - -- A non-positive budget means "nothing fits", not "everything fits": the - -- old early-out returned the whole string, which is how a phone-width - -- status bar ended up with two lines of text stacked on top of each other - -- (#715). - if maxW <= 0 then return "" end - if font:getWidth(text) <= maxW then return text end - local ell = "..." - local ew = font:getWidth(ell) - while #text > 0 and font:getWidth(text) + ew > maxW do - text = text:sub(1, #text - 1) - end - return text .. ell -end - -function Theme.ellipsizeLeft(font, text, maxW) - text = tostring(text or "") - if not font then return text end - if maxW <= 0 then return "" end -- same rule as Theme.ellipsize (#715) - if font:getWidth(text) <= maxW then return text end - local ell = "..." - local ew = font:getWidth(ell) - while #text > 0 and font:getWidth(text) + ew > maxW do - text = text:sub(2) - end - return ell .. text -end - --- ------------------------------------------------------------- backgrounds --- The radial navy field, drawn as a triangle fan from the top-centre so the --- falloff matches the CSS radial-gradient in the spec. The screen is cleared --- to the outer colour first so the corners the fan misses match seamlessly. function Theme.field(w, h) - if not G then return end - G.clear(PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1) - if not probe("newMesh") then return end - local cx, cy = w / 2, 0 - local rx, ry = w * 1.3, h * 1.08 - local n = 64 - local verts = { { cx, cy, 0, 0, - PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } - for i = 0, n do - local a = (i / n) * math.pi * 2 - verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, - PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 } - end - local mesh = G.newMesh(verts, "fan", "static") - G.setColor(1, 1, 1, 1) - G.draw(mesh) -end - --- The 6px tri-colour rail across the very top of both windows. -function Theme.versionRail(x, y, w, h) - if not G then return end - local seg = w / 3 - local bars = { PAL.railRed, PAL.railBlue, PAL.railGold } - for i, c in ipairs(bars) do - col(c, 1) - G.rectangle("fill", x + (i - 1) * seg, y, seg, h) - end -end - --- A percentage meter (HP, box fill, dex completion, bag slots). pct is 0-100. -function Theme.meter(x, y, w, h, pct, c) - if not G then return end - col(PAL.cardBorder, 0.18) - G.rectangle("fill", x, y, w, h, h / 2, h / 2) - local fill = w * clamp((pct or 0) / 100, 0, 1) - if fill > 0 then - col(c or PAL.blue, 1) - G.rectangle("fill", x, y, math.max(fill, h / 2), h, h / 2, h / 2) - end -end - --- Font set, rebuilt only when the window size changes. `s` is the same --- height/768 scale the launcher derives, so both windows step together. --- Chrome is the default UI face; save DATA is drawn in the mono face, which --- LOVE only ships as the default vector font -- so "mono" here means the --- same face at a tighter size, and the distinction is carried by size and --- colour. A stub with no newFont returns nil fonts and every draw no-ops. -function Theme.fonts(s) - if not probe("newFont") then return {} end - local function f(px) return G.newFont(math.max(8, math.floor(px + 0.5))) end - return { - scale = s, - wordmark = f(14 * s), - brand = f(11 * s), - chip = f(11 * s), -- RED / BLUE version chip - tile = f(13 * s), -- 2-letter tab glyph - tab = f(13 * s), -- tab label - button = f(14 * s), - small = f(12 * s), - tiny = f(11 * s), - micro = f(10 * s), - caption = f(12 * s), -- letterspaced section captions - mono = f(12 * s), - monoRow = f(13 * s), - monoBig = f(18 * s), - title = f(24 * s), -- inspector species name - headline = f(26 * s), -- dex completion / money - stat = f(19 * s), - } + Shared.field(w, h) end return Theme diff --git a/tools/save-editor/panels/ItemPicker.lua b/tools/save-editor/panels/ItemPicker.lua new file mode 100644 index 00000000..0bb91dc8 --- /dev/null +++ b/tools/save-editor/panels/ItemPicker.lua @@ -0,0 +1,148 @@ +-- Type-to-search item picker: the items panel's answer to the species +-- picker (panels/SpeciesPicker.lua), and built to the same shape on purpose. +-- +-- Adding an item used to mean an inline catalog card wedged into the Items +-- tab: a search field and a scrolling list competing for height with the bag +-- and PC lists beside it, which on a phone left about three rows visible. +-- Adding a Pokemon was already a full-screen modal with the whole window to +-- work with, and there was no reason for the two to differ. +-- +-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises +-- Kit.blockClicks over the chrome and the panel while this is open and lowers +-- it only for this overlay; nothing underneath can take the same tap. + +local Theme = require("Theme") +local Ops = require("Ops") +local PAL = Theme.PAL + +local Picker = {} + +local FIELD_ID = "item-picker" + +function Picker.results(S) + local p = S.itemPicker + return Ops.itemSearch(S, p and p.query or "") +end + +-- Enter commits the top match into whichever destination the picker was +-- opened for, which is the whole point of a search field. +function Picker.commitFirst(S, Kit) + local hits = Picker.results(S) + if not hits[1] then return Ops.say(S, "No item matches that") end + return Picker.commit(S, Kit, hits[1]) +end + +-- One funnel for both destinations. The picker stays OPEN after a commit: +-- stocking a save means adding several items in a row, and reopening the +-- modal per item is the kind of friction the inline card at least did not +-- have. Escape / Close / tap-outside is the way out. +function Picker.commit(S, Kit, id) + local p = S.itemPicker + local dest = (p and p.dest) or "bag" + if dest == "pc" then return Ops.addToPc(S, id) end + return Ops.addToBag(S, id) +end + +function Picker.draw(S, Kit, width, height) + local p = S.itemPicker + if not p then return end + local s = Kit.scale + + -- The click that opened the picker is still the frame's click: the panel + -- dispatches earlier in App.draw than this overlay does, so without + -- swallowing it the scrim below would read it as a tap outside and shut the + -- picker in the same frame it went up. + if p.opened then + p.opened = nil + Kit.blockClicks = true + end + + -- the scrim doubles as the "tap outside to cancel" target + Theme.col(PAL.bg, 0.82) + love.graphics.rectangle("fill", 0, 0, width, height) + + local w = math.min(width - 32 * s, 520 * s) + local h = math.min(height - 32 * s, 560 * s) + local x = (width - w) / 2 + local y = (height - h) / 2 + if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then + Ops.closeItemPicker(S, Kit) + return + end + + Kit.card(x, y, w, h) + local pad = 18 * s + local cx, cy = x + pad, y + pad + local inner = w - 2 * pad + + Kit.caption(cx, cy, "ADD AN ITEM") + local closeW = 30 * s + if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + { font = "small" }) then + Ops.closeItemPicker(S, Kit) + return + end + cy = cy + Kit.textHeight("caption") + 10 * s + + -- Destination toggle. Which list an item lands in is the only real choice + -- here, so it is a pair of chips at the top rather than two buttons at the + -- bottom that each mean "commit, and also pick a destination". + local half = (inner - 8 * s) / 2 + local destH = 30 * s + if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then + p.dest = "bag" + end + if Kit.chip(cx + half + 8 * s, cy, half, destH, "-> PC", p.dest == "pc", + PAL.green, PAL.steel) then + p.dest = "pc" + end + cy = cy + destH + 10 * s + + local fieldH = 34 * s + p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, + "type an item id") + cy = cy + fieldH + 10 * s + + local hits = Picker.results(S) + local rowH = 36 * s + local rowGap = 6 * s + local pagerH = 30 * s + local listH = (y + h - pad - pagerH - 10 * s) - cy + local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) + p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) + -- wheel / touch drag scroll the modal list too; the shield is already + -- lowered for this layer, so Kit.scroll works here and only here + p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + + if #hits == 0 then + Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") + else + Kit.pushClip(cx, cy, inner, listH) + for i = 1, perPage do + local id = hits[p.offset + i] + if not id then break end + local ry = cy + (i - 1) * (rowH + rowGap) + if Kit.row(cx, ry, inner, rowH, false, PAL.green, 9 * s) then + Picker.commit(S, Kit, id) + end + -- how many the save already holds, so a second add is an informed one + local have = (S.save.inventory and S.save.inventory[id]) + or (Ops.pcItems(S) or {})[id] + local tail = have and ("x%d"):format(have) or "" + local tailW = Kit.textWidth("tiny", tail) + Kit.text("monoRow", + Kit.ellipsize("monoRow", id, inner - tailW - 30 * s), cx + 10 * s, + ry + (rowH - Kit.textHeight("monoRow")) / 2, PAL.text) + if tail ~= "" then + Kit.textRight("tiny", tail, cx + inner - 10 * s, + ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) + end + end + Kit.popClip() + Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + end + + p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) +end + +return Picker diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index b78d45cf..9b93d3a3 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -24,11 +24,6 @@ local M = {} local MONEY_STEPS = { -1000, -100, 100, 1000 } -local function matches(id, query) - if query == "" then return true end - return id:lower():find(query:lower(), 1, true) ~= nil -end - -- One quantity row shape, shared by the bag and the PC list: id, qty, then -- the -/+/drop cluster. Returns true when the row body was clicked. local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlus, onDrop) @@ -127,76 +122,33 @@ local function drawBadges(S, Kit, x, y, w, h) end end +-- The "add an item" card. It used to hold the whole catalog inline: a search +-- field plus a scrolling list, sharing this tab's height with the bag and PC +-- lists beside it, which on a phone left about three catalog rows visible. +-- Adding a Pokemon was already a full-screen modal; adding an item now opens +-- the same kind (panels/ItemPicker.lua), so this card is just the door. local function drawPicker(S, Kit, x, y, w, h) local s = Kit.scale local pad = 16 * s Kit.card(x, y, w, h) Kit.caption(x + pad, y + pad, "ADD ITEM") - local qy = y + pad + Kit.textHeight("caption") + 8 * s - local prevQuery = S.itemQuery or "" - S.itemQuery = Kit.textfield("item-query", x + pad, qy, w - 2 * pad, 32 * s, - S.itemQuery or "", "search item ids...") - -- a new query is a new list: keep the first hit on screen rather than - -- leaving the view parked wherever the old result set had scrolled to - if S.itemQuery ~= prevQuery then S.itemPickOffset = 0 end + local cy = y + pad + Kit.textHeight("caption") + 10 * s + local inner = w - 2 * pad - local choices = {} - for _, id in ipairs(S.cat.items) do - if not Ops.isBadgeId(id) and matches(id, S.itemQuery) then - choices[#choices + 1] = id - end - end - if not S.selectedItemId or not matches(S.selectedItemId, S.itemQuery) then - S.selectedItemId = choices[1] - end + Kit.text("mono", Kit.ellipsize("mono", + "Search the full item list and add to the bag or the PC.", inner), + x + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("mono") + 12 * s - local addH = 32 * s - local addY = y + h - pad - addH - local listTop = qy + 32 * s + 10 * s - local listBottom = addY - 10 * s - local cRowH = 28 * s - local cGap = 5 * s - local visible = math.max(1, math.floor((listBottom - listTop) / (cRowH + cGap))) - -- #595: the wheel drives the same offset a pager would, so the whole - -- catalog is reachable with the mouse alone. Kit.scroll clamps, which is - -- also what pulls the view back when a narrower query shortens the list. - S.itemPickOffset = Kit.scroll(x + pad, listTop, w - 2 * pad, - listBottom - listTop, S.itemPickOffset or 0, #choices, visible) - Kit.pushClip(x + pad, listTop, w - 2 * pad, listBottom - listTop) - for i = 1, math.min(visible, #choices - S.itemPickOffset) do - local id = choices[S.itemPickOffset + i] - local ry = listTop + (i - 1) * (cRowH + cGap) - if Kit.row(x + pad, ry, w - 2 * pad, cRowH, id == S.selectedItemId, - PAL.green, 8 * s) then - S.selectedItemId = id - Ops.say(S, "Picked " .. id) - end - Kit.text("mono", Kit.ellipsize("mono", id, w - 2 * pad - 20 * s), - x + pad + 10 * s, ry + (cRowH - Kit.textHeight("mono")) / 2, PAL.text) + local btnH = math.max(34 * s, 34) + local half = (inner - 8 * s) / 2 + if Kit.button(x + pad, cy, half, btnH, "+ Add to bag", + { font = "small", kind = "primary" }) then + Ops.openItemPicker(S, Kit, "bag") end - Kit.popClip() - -- the drag/wheel offset is also made visible: on a phone the list looked - -- bottomless-yet-stuck without an indicator (#715) - Kit.scrollbar(x + pad, listTop, w - 2 * pad, listBottom - listTop, - S.itemPickOffset, #choices, visible) - -- the position counter rides the caption line, where it can never collide - -- with the list body or the two add buttons below it - if #choices > visible then - Kit.textRight("micro", ("%d-%d of %d"):format(S.itemPickOffset + 1, - math.min(S.itemPickOffset + visible, #choices), #choices), - x + w - pad, y + pad, PAL.faint) - elseif #choices == 0 then - Kit.text("mono", "no item matches", x + pad + 10 * s, listTop + 8 * s, PAL.faint) - end - - local halfW = (w - 2 * pad - 8 * s) / 2 - if Kit.button(x + pad, addY, halfW, addH, "-> Bag", - { font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then - Ops.addToBag(S, S.selectedItemId) - end - if Kit.button(x + pad + halfW + 8 * s, addY, halfW, addH, "-> PC", - { font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then - Ops.addToPc(S, S.selectedItemId) + if Kit.button(x + pad + half + 8 * s, cy, half, btnH, "+ Add to PC", + { font = "small", kind = "accent" }) then + Ops.openItemPicker(S, Kit, "pc") end end