From af47e19e1adce9fa2acdd88238391a1f76bd010e Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 4 Aug 2026 15:17:09 -0400 Subject: [PATCH] Rebuild the launcher and save editor on a small immediate-mode UI kit The launcher spent ~9ms per frame building and drawing, and the Find Mods tab could hang the window for minutes. Both had the same root cause: a retained UI tree rebuilt every frame, and blocking curl calls made from the draw path. Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit, Theme, Layout, Loader). The kit caches Text objects and all measurement, allocates nothing in the steady state, and draws flat. Build+draw is now under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF). Move every network call off the render thread onto a love.thread pool (src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab stats, thumbnails and mod installs. Mod indexes prewarm at boot so the Find Mods tab is populated before it is opened. Paginate every list -- mods, find, save slots, settings, release notes, versions -- with the page size derived from the real viewport height, so a 500-mod index costs what a 10-mod one does. Scrolling is gone. Anything that waits now raises a non-dismissable loader; per-row background work shows an inline spinner instead. The in-app updater moves to the top right beside the settings gear and pulses when an update is waiting. Theme is black with white outlines, no gradients or glows, and solid colour-coded embossed buttons with bold labels. The game tabs keep their cartridge colours. Everything is 1.3x larger. The save editor shares the theme, and adding an item there is now a searchable pop-up like adding a Pokemon. Also: - Reset rebinds, in Settings and under Touch Controls. Rebinds are additive (Input:applyBindings layers them over the defaults), so there was no in-game way to undo one. - Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into a game for shortcuts and frontends, falling back to that game's tab when its ROM is not imported. Fixes found while porting: - Ellipsis and letterspacing truncated bytes, not codepoints, so a multi-byte mod name crashed the first frame on a Japanese index. Measurement no longer throws on malformed input either. - The new font set missed UiFont's kana fallback, rendering translated builds as tofu. - Fetch workers idle in Channel:demand() and LOVE waits for live threads at exit, so the process outlived the window; quitting mid-download also waited on curl's 300s ceiling. Shut the pool down in love.quit and bound its transfer timeouts. - In one column the save-slot card drew below the fold, over the footer, with no scrollbar left to reach it. The two FlexLove engine tests guarded a scroll manager and an auto-height propagation bug that no longer exist; replace them with a kit suite covering page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test to assert the dependency is gone rather than that its perf guards are set. --- docs/new-features.md | 56 + libs/flexlove/FlexLove.lua | 1955 -------- libs/flexlove/LICENSE | 21 - libs/flexlove/modules/Animation.lua | 1579 ------- libs/flexlove/modules/Behavior.lua | 188 - libs/flexlove/modules/Blur.lua | 686 --- libs/flexlove/modules/Calc.lua | 385 -- libs/flexlove/modules/Color.lua | 346 -- libs/flexlove/modules/Context.lua | 596 --- libs/flexlove/modules/Element.lua | 3920 ----------------- libs/flexlove/modules/Enums.lua | 171 - libs/flexlove/modules/ErrorHandler.lua | 1042 ----- libs/flexlove/modules/EventHandler.lua | 843 ---- libs/flexlove/modules/FocusIndicator.lua | 232 - libs/flexlove/modules/FontCache.lua | 274 -- libs/flexlove/modules/GestureRecognizer.lua | 583 --- libs/flexlove/modules/Grid.lua | 336 -- libs/flexlove/modules/ImageCache.lua | 160 - libs/flexlove/modules/ImageRenderer.lua | 380 -- libs/flexlove/modules/ImageScaler.lua | 174 - libs/flexlove/modules/InputEvent.lua | 88 - libs/flexlove/modules/KeyboardNavigation.lua | 748 ---- libs/flexlove/modules/LayoutEngine.lua | 1714 ------- libs/flexlove/modules/MemoryScanner.lua | 697 --- libs/flexlove/modules/ModuleLoader.lua | 202 - libs/flexlove/modules/NinePatch.lua | 217 - libs/flexlove/modules/NumberValidation.lua | 351 -- libs/flexlove/modules/PathValidator.lua | 198 - libs/flexlove/modules/Performance.lua | 560 --- libs/flexlove/modules/PropertySchema.lua | 505 --- libs/flexlove/modules/Renderer.lua | 1230 ------ libs/flexlove/modules/RoundedRect.lua | 124 - libs/flexlove/modules/ScrollManager.lua | 1525 ------- libs/flexlove/modules/Select.lua | 719 --- libs/flexlove/modules/StateManager.lua | 790 ---- libs/flexlove/modules/TextEditor.lua | 1783 -------- libs/flexlove/modules/TextSanitizer.lua | 183 - libs/flexlove/modules/Theme.lua | 1655 ------- libs/flexlove/modules/UTF8.lua | 44 - libs/flexlove/modules/Units.lua | 335 -- libs/flexlove/modules/ZIndex.lua | 35 - libs/flexlove/modules/behaviors/Animated.lua | 245 -- libs/flexlove/modules/behaviors/Clickable.lua | 344 -- libs/flexlove/modules/behaviors/Imageable.lua | 282 -- .../modules/behaviors/Persistable.lua | 132 - .../flexlove/modules/behaviors/Scrollable.lua | 267 -- .../flexlove/modules/behaviors/Selectable.lua | 206 - .../modules/behaviors/TextEditable.lua | 576 --- libs/flexlove/modules/behaviors/Themed.lua | 178 - libs/flexlove/modules/types.lua | 662 --- libs/flexlove/modules/utils.lua | 319 -- main.lua | 27 + src/core/HostShell.lua | 16 +- src/core/LaunchOptions.lua | 113 + src/import/LauncherSettings.lua | 20 + src/import/LauncherView.lua | 3144 ++++++------- src/import/RomImporter.lua | 777 +++- src/mods/LauncherMods.lua | 24 + src/mods/ModIndex.lua | 95 + src/mods/ModUpdate.lua | 111 + src/net/Fetch.lua | 188 + src/net/fetch_worker.lua | 104 + src/ui/kit/Kit.lua | 917 ++++ src/ui/kit/Layout.lua | 115 + src/ui/kit/Loader.lua | 133 + src/ui/kit/Theme.lua | 381 ++ tests/engine/flexlove_wheel_scroll_dt0.lua | 63 - tests/engine/gate_strings_coverage.lua | 2 + tests/engine/launcher_nx_pad_cursor_test.lua | 35 +- .../launcher_save_slot_overlap_bug748.lua | 173 - tests/engine/ui_kit_pagination.lua | 127 + tools/save-editor/App.lua | 44 +- tools/save-editor/Kit.lua | 80 +- tools/save-editor/Ops.lua | 30 + tools/save-editor/State.lua | 6 + tools/save-editor/Theme.lua | 382 +- tools/save-editor/panels/ItemPicker.lua | 148 + tools/save-editor/panels/Items.lua | 86 +- 78 files changed, 4785 insertions(+), 33397 deletions(-) delete mode 100644 libs/flexlove/FlexLove.lua delete mode 100644 libs/flexlove/LICENSE delete mode 100644 libs/flexlove/modules/Animation.lua delete mode 100644 libs/flexlove/modules/Behavior.lua delete mode 100644 libs/flexlove/modules/Blur.lua delete mode 100644 libs/flexlove/modules/Calc.lua delete mode 100644 libs/flexlove/modules/Color.lua delete mode 100644 libs/flexlove/modules/Context.lua delete mode 100644 libs/flexlove/modules/Element.lua delete mode 100644 libs/flexlove/modules/Enums.lua delete mode 100644 libs/flexlove/modules/ErrorHandler.lua delete mode 100644 libs/flexlove/modules/EventHandler.lua delete mode 100644 libs/flexlove/modules/FocusIndicator.lua delete mode 100644 libs/flexlove/modules/FontCache.lua delete mode 100644 libs/flexlove/modules/GestureRecognizer.lua delete mode 100644 libs/flexlove/modules/Grid.lua delete mode 100644 libs/flexlove/modules/ImageCache.lua delete mode 100644 libs/flexlove/modules/ImageRenderer.lua delete mode 100644 libs/flexlove/modules/ImageScaler.lua delete mode 100644 libs/flexlove/modules/InputEvent.lua delete mode 100644 libs/flexlove/modules/KeyboardNavigation.lua delete mode 100644 libs/flexlove/modules/LayoutEngine.lua delete mode 100644 libs/flexlove/modules/MemoryScanner.lua delete mode 100644 libs/flexlove/modules/ModuleLoader.lua delete mode 100644 libs/flexlove/modules/NinePatch.lua delete mode 100644 libs/flexlove/modules/NumberValidation.lua delete mode 100644 libs/flexlove/modules/PathValidator.lua delete mode 100644 libs/flexlove/modules/Performance.lua delete mode 100644 libs/flexlove/modules/PropertySchema.lua delete mode 100644 libs/flexlove/modules/Renderer.lua delete mode 100644 libs/flexlove/modules/RoundedRect.lua delete mode 100644 libs/flexlove/modules/ScrollManager.lua delete mode 100644 libs/flexlove/modules/Select.lua delete mode 100644 libs/flexlove/modules/StateManager.lua delete mode 100644 libs/flexlove/modules/TextEditor.lua delete mode 100644 libs/flexlove/modules/TextSanitizer.lua delete mode 100644 libs/flexlove/modules/Theme.lua delete mode 100644 libs/flexlove/modules/UTF8.lua delete mode 100644 libs/flexlove/modules/Units.lua delete mode 100644 libs/flexlove/modules/ZIndex.lua delete mode 100644 libs/flexlove/modules/behaviors/Animated.lua delete mode 100644 libs/flexlove/modules/behaviors/Clickable.lua delete mode 100644 libs/flexlove/modules/behaviors/Imageable.lua delete mode 100644 libs/flexlove/modules/behaviors/Persistable.lua delete mode 100644 libs/flexlove/modules/behaviors/Scrollable.lua delete mode 100644 libs/flexlove/modules/behaviors/Selectable.lua delete mode 100644 libs/flexlove/modules/behaviors/TextEditable.lua delete mode 100644 libs/flexlove/modules/behaviors/Themed.lua delete mode 100644 libs/flexlove/modules/types.lua delete mode 100644 libs/flexlove/modules/utils.lua create mode 100644 src/core/LaunchOptions.lua create mode 100644 src/net/Fetch.lua create mode 100644 src/net/fetch_worker.lua create mode 100644 src/ui/kit/Kit.lua create mode 100644 src/ui/kit/Layout.lua create mode 100644 src/ui/kit/Loader.lua create mode 100644 src/ui/kit/Theme.lua delete mode 100644 tests/engine/flexlove_wheel_scroll_dt0.lua delete mode 100644 tests/engine/launcher_save_slot_overlap_bug748.lua create mode 100644 tests/engine/ui_kit_pagination.lua create mode 100644 tools/save-editor/panels/ItemPicker.lua 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