big ui moment

This commit is contained in:
bryanthaboi
2026-08-03 11:50:49 -04:00
parent 8e5501a23b
commit f0f5bc7551
78 changed files with 34435 additions and 3519 deletions
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
-- 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
+686
View File
@@ -0,0 +1,686 @@
-- 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
+385
View File
@@ -0,0 +1,385 @@
--- 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
+346
View File
@@ -0,0 +1,346 @@
---@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
+596
View File
@@ -0,0 +1,596 @@
---@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<Element> 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
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
-- 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 }
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
---@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<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
---@field _clickCount number
---@field _dragStartX table<number, number>
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, 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<string, 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
+232
View File
@@ -0,0 +1,232 @@
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
+269
View File
@@ -0,0 +1,269 @@
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
-- 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,
}
+583
View File
@@ -0,0 +1,583 @@
---@class GestureRecognizer
---@field _touches table<string, 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
+336
View File
@@ -0,0 +1,336 @@
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
+160
View File
@@ -0,0 +1,160 @@
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<string, {image: love.Image, imageData: love.ImageData?}>
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
+380
View File
@@ -0,0 +1,380 @@
---@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
+174
View File
@@ -0,0 +1,174 @@
-- ====================
-- 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
+88
View File
@@ -0,0 +1,88 @@
---@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
@@ -0,0 +1,748 @@
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<Element> 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<Element> 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
File diff suppressed because it is too large Load Diff
+697
View File
@@ -0,0 +1,697 @@
---@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
+202
View File
@@ -0,0 +1,202 @@
---@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
+217
View File
@@ -0,0 +1,217 @@
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
+351
View File
@@ -0,0 +1,351 @@
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,
}
+198
View File
@@ -0,0 +1,198 @@
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,
}
+560
View File
@@ -0,0 +1,560 @@
---@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
+505
View File
@@ -0,0 +1,505 @@
-- 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 `on<Name>Deferred`
-- 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<string, 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<string, 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
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
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
File diff suppressed because it is too large Load Diff
+719
View File
@@ -0,0 +1,719 @@
---@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
+790
View File
@@ -0,0 +1,790 @@
---@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
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
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("&", "&amp;")
text = text:gsub("<", "&lt;")
text = text:gsub(">", "&gt;")
text = text:gsub('"', "&quot;")
text = text:gsub("'", "&#39;")
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,
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
---@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
+335
View File
@@ -0,0 +1,335 @@
--- 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
+35
View File
@@ -0,0 +1,35 @@
---@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
@@ -0,0 +1,245 @@
-- 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
@@ -0,0 +1,344 @@
-- 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
@@ -0,0 +1,282 @@
-- 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
@@ -0,0 +1,132 @@
-- 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
@@ -0,0 +1,264 @@
-- 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
-- Clear any parent scissor clipping before drawing scrollbars so they render
-- fully visible (scrollbars must not be clipped by ancestor overflow).
love.graphics.setScissor()
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
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
@@ -0,0 +1,206 @@
-- 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
@@ -0,0 +1,576 @@
-- 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.<fn>(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
+178
View File
@@ -0,0 +1,178 @@
-- 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
+662
View File
@@ -0,0 +1,662 @@
---@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<string, EasingFunction|fun(...):EasingFunction> -- 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<integer, string>, vertical:table<integer, string>}?
---@field states table<string, ThemeComponent>?
---@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<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent>?
---@field colors table<string, Color>?
---@field fonts table<string, string>?
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
local ThemeDefinition = {}
---@class Theme
---@field name string
---@field atlas love.Image?
---@field atlasData love.ImageData?
---@field components table<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent>
---@field colors table<string, Color>
---@field fonts table<string, string>
---@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<string, boolean>
---@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<string, Theme>
---@field getColorNames fun(): string[]
---@field getAllColors fun(): table<string, Color>
---@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
+319
View File
@@ -0,0 +1,319 @@
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,
}