continued refactor

This commit is contained in:
Michael Freno
2025-11-19 14:10:18 -05:00
parent b24af17179
commit 32eda9ff8b
7 changed files with 830 additions and 1310 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +1,34 @@
local Blur = {} local Cache = {
canvases = {},
-- Canvas cache to avoid recreating canvases every frame quads = {},
local canvasCache = {} MAX_CANVAS_SIZE = 20,
local MAX_CACHE_SIZE = 20 MAX_QUAD_SIZE = 20,
}
-- Quad cache to avoid recreating quads every frame
local quadCache = {}
local MAX_QUAD_CACHE_SIZE = 20
-- Quad cache to avoid recreating quads every frame
local quadCache = {}
local MAX_QUAD_CACHE_SIZE = 20
--- Build Gaussian blur shader with given parameters
---@param taps number -- Number of samples (must be odd, >= 3)
---@param offset number
---@param offset_type string -- "weighted" or "center"
---@param sigma number
---@return love.Shader
local function buildShader(taps, offset, offset_type, 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 g_offsets = {}
local g_weights = {}
for i = 1, steps, 1 do
g_offsets[i] = offset * (i - 1)
g_weights[i] = math.exp(-0.5 * (g_offsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
end
-- Calculate offsets and weights for sub-pixel samples
local offsets = {}
local weights = {}
for i = #g_weights, 2, -2 do
local oA, oB = g_offsets[i], g_offsets[i - 1]
local wA, wB = g_weights[i], g_weights[i - 1]
wB = oB == 0 and wB / 2 or wB
local weight = wA + wB
offsets[#offsets + 1] = offset_type == "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 #g_weights % 2 == 0 then
code[#code + 1] = "vec4 c = vec4( 0.0 );"
else
local weight = g_weights[1]
norm = norm + weight
code[#code + 1] = ("vec4 c = %f * texture2D(tex, tc);"):format(weight)
end
local tmpl = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
for i = 1, #offsets, 1 do
local offset = offsets[i]
local weight = weights[i]
norm = norm + weight * 2
code[#code + 1] = tmpl:format(weight, offset, offset)
end
code[#code + 1] = ("return c * vec4(%f) * color; }"):format(1 / norm)
local shader = table.concat(code)
return love.graphics.newShader(shader)
end
--- Get or create a canvas from cache --- Get or create a canvas from cache
---@param width number ---@param width number Canvas width
---@param height number ---@param height number Canvas height
---@return love.Canvas ---@return love.Canvas canvas The cached or new canvas
local function getCanvas(width, height) function Cache.getCanvas(width, height)
local key = string.format("%dx%d", width, height) local key = string.format("%dx%d", width, height)
if not canvasCache[key] then if not Cache.canvases[key] then
canvasCache[key] = {} Cache.canvases[key] = {}
end end
local cache = canvasCache[key] local cache = Cache.canvases[key]
for i, canvas in ipairs(cache) do for i, entry in ipairs(cache) do
if not canvas.inUse then if not entry.inUse then
canvas.inUse = true entry.inUse = true
return canvas.canvas return entry.canvas
end end
end end
local canvas = love.graphics.newCanvas(width, height) local canvas = love.graphics.newCanvas(width, height)
table.insert(cache, { canvas = canvas, inUse = true }) table.insert(cache, { canvas = canvas, inUse = true })
if #cache > MAX_CACHE_SIZE then if #cache > Cache.MAX_CANVAS_SIZE then
table.remove(cache, 1) table.remove(cache, 1)
end end
@@ -101,9 +36,9 @@ local function getCanvas(width, height)
end end
--- Release a canvas back to the cache --- Release a canvas back to the cache
---@param canvas love.Canvas ---@param canvas love.Canvas Canvas to release
local function releaseCanvas(canvas) function Cache.releaseCanvas(canvas)
for _, sizeCache in pairs(canvasCache) do for _, sizeCache in pairs(Cache.canvases) do
for _, entry in ipairs(sizeCache) do for _, entry in ipairs(sizeCache) do
if entry.canvas == canvas then if entry.canvas == canvas then
entry.inUse = false entry.inUse = false
@@ -114,21 +49,21 @@ local function releaseCanvas(canvas)
end end
--- Get or create a quad from cache --- Get or create a quad from cache
---@param x number ---@param x number X position
---@param y number ---@param y number Y position
---@param width number ---@param width number Quad width
---@param height number ---@param height number Quad height
---@param sw number -- Source width ---@param sw number Source width
---@param sh number -- Source height ---@param sh number Source height
---@return love.Quad ---@return love.Quad quad The cached or new quad
local function getQuad(x, y, width, height, sw, sh) 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) local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not quadCache[key] then if not Cache.quads[key] then
quadCache[key] = {} Cache.quads[key] = {}
end end
local cache = quadCache[key] local cache = Cache.quads[key]
for i, entry in ipairs(cache) do for i, entry in ipairs(cache) do
if not entry.inUse then if not entry.inUse then
@@ -140,7 +75,7 @@ local function getQuad(x, y, width, height, sw, sh)
local quad = love.graphics.newQuad(x, y, width, height, sw, sh) local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true }) table.insert(cache, { quad = quad, inUse = true })
if #cache > MAX_QUAD_CACHE_SIZE then if #cache > Cache.MAX_QUAD_SIZE then
table.remove(cache, 1) table.remove(cache, 1)
end end
@@ -148,9 +83,9 @@ local function getQuad(x, y, width, height, sw, sh)
end end
--- Release a quad back to the cache --- Release a quad back to the cache
---@param quad love.Quad ---@param quad love.Quad Quad to release
local function releaseQuad(quad) function Cache.releaseQuad(quad)
for _, keyCache in pairs(quadCache) do for _, keyCache in pairs(Cache.quads) do
for _, entry in ipairs(keyCache) do for _, entry in ipairs(keyCache) do
if entry.quad == quad then if entry.quad == quad then
entry.inUse = false entry.inUse = false
@@ -160,11 +95,96 @@ local function releaseQuad(quad)
end end
end end
--- Create a blur effect instance --- Clear all caches
---@param quality number -- Quality level (1-10, higher = better quality but slower) function Cache.clear()
---@return table -- Blur effect instance Cache.canvases = {}
function Blur.new(quality) Cache.quads = {}
quality = math.max(1, math.min(10, quality or 5)) 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
---@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
--- 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))
-- Map quality to shader parameters -- Map quality to shader parameters
-- Quality 1: 3 taps (fastest, lowest quality) -- Quality 1: 3 taps (fastest, lowest quality)
@@ -173,33 +193,38 @@ function Blur.new(quality)
local taps = 3 + (quality - 1) * 1.5 local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps) taps = math.floor(taps)
if taps % 2 == 0 then if taps % 2 == 0 then
taps = taps + 1 -- Ensure odd number taps = taps + 1
end end
local offset = 1.0 local offset = 1.0
local offset_type = "weighted" local offsetType = "weighted"
local sigma = -1 local sigma = -1
local shader = buildShader(taps, offset, offset_type, sigma) local shader = ShaderBuilder.build(taps, offset, offsetType, sigma)
local instance = { local self = setmetatable({}, Blur)
shader = shader, self.shader = shader
quality = quality, self.quality = quality
taps = taps, self.taps = taps
}
return instance return self
end end
--- Apply blur to a region of the screen --- Apply blur to a region of the screen
---@param blurInstance table -- Blur effect instance from Blur.new() ---@param intensity number Blur intensity (0-100)
---@param intensity number -- Blur intensity (0-100) ---@param x number X position
---@param x number -- X position ---@param y number Y position
---@param y number -- Y position ---@param width number Width of region
---@param width number -- Width ---@param height number Height of region
---@param height number -- Height ---@param drawFunc function Function to draw content to be blurred
---@param drawFunc function -- Function to draw content to be blurred function Blur:applyToRegion(intensity, x, y, width, height, drawFunc)
function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFunc) if type(drawFunc) ~= "function" then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "applyToRegion requires a draw function.")
end
return
end
if intensity <= 0 or width <= 0 or height <= 0 then if intensity <= 0 or width <= 0 or height <= 0 then
drawFunc() drawFunc()
return return
@@ -211,8 +236,8 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu
local passes = math.ceil(intensity / 20) local passes = math.ceil(intensity / 20)
passes = math.max(1, math.min(5, passes)) passes = math.max(1, math.min(5, passes))
local canvas1 = getCanvas(width, height) local canvas1 = Cache.getCanvas(width, height)
local canvas2 = getCanvas(width, height) local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas() local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader() local prevShader = love.graphics.getShader()
@@ -227,19 +252,19 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu
drawFunc() drawFunc()
love.graphics.pop() love.graphics.pop()
love.graphics.setShader(blurInstance.shader) love.graphics.setShader(self.shader)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied") love.graphics.setBlendMode("alpha", "premultiplied")
for i = 1, passes do for i = 1, passes do
love.graphics.setCanvas(canvas2) love.graphics.setCanvas(canvas2)
love.graphics.clear() love.graphics.clear()
blurInstance.shader:send("direction", { 1 / width, 0 }) self.shader:send("direction", { 1 / width, 0 })
love.graphics.draw(canvas1, 0, 0) love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1) love.graphics.setCanvas(canvas1)
love.graphics.clear() love.graphics.clear()
blurInstance.shader:send("direction", { 0, 1 / height }) self.shader:send("direction", { 0, 1 / height })
love.graphics.draw(canvas2, 0, 0) love.graphics.draw(canvas2, 0, 0)
end end
@@ -251,19 +276,25 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu
love.graphics.setShader(prevShader) love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor)) love.graphics.setColor(unpack(prevColor))
releaseCanvas(canvas1) Cache.releaseCanvas(canvas1)
releaseCanvas(canvas2) Cache.releaseCanvas(canvas2)
end end
--- Apply backdrop blur effect (blur content behind a region) --- Apply backdrop blur effect (blur content behind a region)
---@param blurInstance table -- Blur effect instance from Blur.new() ---@param intensity number Blur intensity (0-100)
---@param intensity number -- Blur intensity (0-100) ---@param x number X position
---@param x number -- X position ---@param y number Y position
---@param y number -- Y position ---@param width number Width of region
---@param width number -- Width ---@param height number Height of region
---@param height number -- Height ---@param backdropCanvas love.Canvas Canvas containing the backdrop content
---@param backdropCanvas love.Canvas -- Canvas containing the backdrop content function Blur:applyBackdrop(intensity, x, y, width, height, backdropCanvas)
function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdropCanvas) if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "applyBackdrop requires a backdrop canvas.")
end
return
end
if intensity <= 0 or width <= 0 or height <= 0 then if intensity <= 0 or width <= 0 or height <= 0 then
return return
end end
@@ -273,8 +304,8 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
local passes = math.ceil(intensity / 20) local passes = math.ceil(intensity / 20)
passes = math.max(1, math.min(5, passes)) passes = math.max(1, math.min(5, passes))
local canvas1 = getCanvas(width, height) local canvas1 = Cache.getCanvas(width, height)
local canvas2 = getCanvas(width, height) local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas() local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader() local prevShader = love.graphics.getShader()
@@ -287,20 +318,20 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
love.graphics.setBlendMode("alpha", "premultiplied") love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions() local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = getQuad(x, y, width, height, backdropWidth, backdropHeight) local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0) love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(blurInstance.shader) love.graphics.setShader(self.shader)
for i = 1, passes do for i = 1, passes do
love.graphics.setCanvas(canvas2) love.graphics.setCanvas(canvas2)
love.graphics.clear() love.graphics.clear()
blurInstance.shader:send("direction", { 1 / width, 0 }) self.shader:send("direction", { 1 / width, 0 })
love.graphics.draw(canvas1, 0, 0) love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1) love.graphics.setCanvas(canvas1)
love.graphics.clear() love.graphics.clear()
blurInstance.shader:send("direction", { 0, 1 / height }) self.shader:send("direction", { 0, 1 / height })
love.graphics.draw(canvas2, 0, 0) love.graphics.draw(canvas2, 0, 0)
end end
@@ -312,15 +343,35 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr
love.graphics.setShader(prevShader) love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor)) love.graphics.setColor(unpack(prevColor))
releaseCanvas(canvas1) Cache.releaseCanvas(canvas1)
releaseCanvas(canvas2) Cache.releaseCanvas(canvas2)
releaseQuad(quad) Cache.releaseQuad(quad)
end end
--- Clear canvas cache (call on window resize) --- Get the current quality level
function Blur.clearCache() ---@return number quality Quality level (1-10)
canvasCache = {} function Blur:getQuality()
quadCache = {} return self.quality
end 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
--- Initialize Blur module with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler? }
function Blur.init(deps)
Blur._ErrorHandler = deps.ErrorHandler
end
Blur.Cache = Cache
Blur.ShaderBuilder = ShaderBuilder
return Blur return Blur

View File

@@ -1,36 +1,3 @@
local ErrorHandler = nil
-- Named colors (CSS3 color names)
local NAMED_COLORS = {
-- Basic colors
black = { 0, 0, 0, 1 },
white = { 1, 1, 1, 1 },
red = { 1, 0, 0, 1 },
green = { 0, 0.502, 0, 1 },
blue = { 0, 0, 1, 1 },
yellow = { 1, 1, 0, 1 },
cyan = { 0, 1, 1, 1 },
magenta = { 1, 0, 1, 1 },
-- Extended colors
gray = { 0.502, 0.502, 0.502, 1 },
grey = { 0.502, 0.502, 0.502, 1 },
silver = { 0.753, 0.753, 0.753, 1 },
maroon = { 0.502, 0, 0, 1 },
olive = { 0.502, 0.502, 0, 1 },
lime = { 0, 1, 0, 1 },
aqua = { 0, 1, 1, 1 },
teal = { 0, 0.502, 0.502, 1 },
navy = { 0, 0, 0.502, 1 },
fuchsia = { 1, 0, 1, 1 },
purple = { 0.502, 0, 0.502, 1 },
orange = { 1, 0.647, 0, 1 },
pink = { 1, 0.753, 0.796, 1 },
brown = { 0.647, 0.165, 0.165, 1 },
transparent = { 0, 0, 0, 0 },
}
--- Utility class for color handling
---@class Color ---@class Color
---@field r number -- Red component (0-1) ---@field r number -- Red component (0-1)
---@field g number -- Green component (0-1) ---@field g number -- Green component (0-1)
@@ -79,13 +46,11 @@ end
function Color.fromHex(hexWithTag) function Color.fromHex(hexWithTag)
-- Validate input type -- Validate input type
if type(hexWithTag) ~= "string" then if type(hexWithTag) ~= "string" then
if ErrorHandler then Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = tostring(hexWithTag),
input = tostring(hexWithTag), issue = "not a string",
issue = "not a string", fallback = "white (#FFFFFF)",
fallback = "white (#FFFFFF)", })
})
end
return Color.new(1, 1, 1, 1) return Color.new(1, 1, 1, 1)
end end
@@ -95,13 +60,11 @@ function Color.fromHex(hexWithTag)
local g = tonumber("0x" .. hex:sub(3, 4)) local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6)) local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then if not r or not g or not b then
if ErrorHandler then Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = hexWithTag,
input = hexWithTag, issue = "invalid hex digits",
issue = "invalid hex digits", fallback = "white (#FFFFFF)",
fallback = "white (#FFFFFF)", })
})
end
return Color.new(1, 1, 1, 1) -- Return white as fallback return Color.new(1, 1, 1, 1) -- Return white as fallback
end end
return Color.new(r / 255, g / 255, b / 255, 1) return Color.new(r / 255, g / 255, b / 255, 1)
@@ -111,25 +74,21 @@ function Color.fromHex(hexWithTag)
local b = tonumber("0x" .. hex:sub(5, 6)) local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8)) local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then if not r or not g or not b or not a then
if ErrorHandler then Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = hexWithTag,
input = hexWithTag, issue = "invalid hex digits",
issue = "invalid hex digits", fallback = "white (#FFFFFFFF)",
fallback = "white (#FFFFFFFF)", })
})
end
return Color.new(1, 1, 1, 1) -- Return white as fallback return Color.new(1, 1, 1, 1) -- Return white as fallback
end end
return Color.new(r / 255, g / 255, b / 255, a / 255) return Color.new(r / 255, g / 255, b / 255, a / 255)
else else
if ErrorHandler then Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = hexWithTag,
input = hexWithTag, expected = "#RRGGBB or #RRGGBBAA",
expected = "#RRGGBB or #RRGGBBAA", hexLength = #hex,
hexLength = #hex, fallback = "white (#FFFFFF)",
fallback = "white (#FFFFFF)", })
})
end
return Color.new(1, 1, 1, 1) -- Return white as fallback return Color.new(1, 1, 1, 1) -- Return white as fallback
end end
end end
@@ -227,23 +186,6 @@ function Color.validateRGBColor(r, g, b, a, max)
return true, nil return true, nil
end end
--- Validate named color
---@param name string Color name (e.g. "red", "blue", "transparent")
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateNamedColor(name)
if type(name) ~= "string" then
return false, "Color name must be a string"
end
local lowerName = name:lower()
if not NAMED_COLORS[lowerName] then
return false, string.format("Unknown color name: '%s'", name)
end
return true, nil
end
--- Check if a value is a valid color format --- Check if a value is a valid color format
---@param value any Value to check ---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid ---@return string? format Format type ("hex", "named", "table"), nil if invalid
@@ -259,11 +201,6 @@ function Color.isValidColorFormat(value)
end end
end end
-- Check for named color
if NAMED_COLORS[value:lower()] then
return "named"
end
return nil return nil
end end
@@ -296,42 +233,6 @@ function Color.isValidColorFormat(value)
return nil return nil
end end
--- Check if a color value is usable before processing to provide clear error messages
--- Use this for config validation and debugging malformed color data
---@param value any Color value to validate
---@param options table? Validation options {allowNamed: boolean, requireAlpha: boolean}
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateColor(value, options)
options = options or {}
local allowNamed = options.allowNamed ~= false
local requireAlpha = options.requireAlpha or false
if value == nil then
return false, "Color value is nil"
end
local format = Color.isValidColorFormat(value)
if not format then
return false, string.format("Invalid color format: %s", tostring(value))
end
if format == "named" and not allowNamed then
return false, "Named colors not allowed"
end
-- Additional validation for alpha requirement
if requireAlpha and format == "hex" then
local cleanHex = value:gsub("^#", "")
if #cleanHex ~= 8 then
return false, "Alpha channel required (use 8-digit hex)"
end
end
return true, nil
end
--- Convert any color format to a valid Color object with graceful fallbacks --- Convert any color format to a valid Color object with graceful fallbacks
--- Use this to robustly handle colors from any source without crashes --- 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 value any Color value to sanitize (hex, named, table, or Color instance)
@@ -364,17 +265,6 @@ function Color.sanitizeColor(value, default)
end end
end end
-- Handle named format
if format == "named" then
local lowerName = value:lower()
local rgba = NAMED_COLORS[lowerName]
if rgba then
return Color.new(rgba[1], rgba[2], rgba[3], rgba[4])
end
return default
end
-- Handle table format
if format == "table" then if format == "table" then
-- Color instance -- Color instance
if getmetatable(value) == Color then if getmetatable(value) == Color then
@@ -450,9 +340,7 @@ end
--- Initialize dependencies --- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler } ---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
function Color.init(deps) function Color.init(deps)
if type(deps) == "table" then Color._ErrorHandler = deps.ErrorHandler
ErrorHandler = deps.ErrorHandler
end
end end
return Color return Color

View File

@@ -1,26 +1,14 @@
---@class Context ---@class Context
local Context = { local Context = {
-- Top-level elements
topElements = {}, topElements = {},
-- Base scale configuration -- Base scale configuration
baseScale = nil, -- {width: number, height: number} baseScale = nil, -- {width: number, height: number}
-- Current scale factors -- Current scale factors
scaleFactors = { x = 1.0, y = 1.0 }, scaleFactors = { x = 1.0, y = 1.0 },
-- Default theme name
defaultTheme = nil, defaultTheme = nil,
-- Currently focused element (for keyboard input)
_focusedElement = nil, _focusedElement = nil,
-- Active event element (for current frame)
_activeEventElement = nil, _activeEventElement = nil,
-- Cached viewport dimensions
_cachedViewport = { width = 0, height = 0 }, _cachedViewport = { width = 0, height = 0 },
-- Immediate mode state -- Immediate mode state
_immediateMode = false, _immediateMode = false,
_frameNumber = 0, _frameNumber = 0,
@@ -28,12 +16,10 @@ local Context = {
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled _immediateModeState = nil, -- Will be initialized if immediate mode is enabled
_frameStarted = false, _frameStarted = false,
_autoBeganFrame = false, _autoBeganFrame = false,
-- Z-index ordered element tracking for immediate mode -- Z-index ordered element tracking for immediate mode
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest) _zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
} }
--- Get current scale factors
---@return number, number -- scaleX, scaleY ---@return number, number -- scaleX, scaleY
function Context.getScaleFactors() function Context.getScaleFactors()
return Context.scaleFactors.x, Context.scaleFactors.y return Context.scaleFactors.x, Context.scaleFactors.y
@@ -49,7 +35,6 @@ function Context.registerElement(element)
table.insert(Context._zIndexOrderedElements, element) table.insert(Context._zIndexOrderedElements, element)
end end
--- Clear frame elements (called at start of each immediate mode frame)
function Context.clearFrameElements() function Context.clearFrameElements()
Context._zIndexOrderedElements = {} Context._zIndexOrderedElements = {}
end end
@@ -143,7 +128,6 @@ function Context.getTopElementAt(x, y)
return nil return nil
end end
-- Traverse from highest to lowest z-index (reverse order)
for i = #Context._zIndexOrderedElements, 1, -1 do for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i] local element = Context._zIndexOrderedElements[i]
@@ -152,7 +136,6 @@ function Context.getTopElementAt(x, y)
if interactive then if interactive then
return interactive return interactive
end end
-- This preserves backward compatibility for non-interactive overlays
return element return element
end end
end end

File diff suppressed because it is too large Load Diff

View File

@@ -254,35 +254,6 @@ function TestEasing:testElasticFactory()
luaunit.assertAlmostEquals(customElastic(1), 1, 0.01) luaunit.assertAlmostEquals(customElastic(1), 1, 0.01)
end end
-- Test Easing.list() method
function TestEasing:testList()
local list = Easing.list()
luaunit.assertEquals(type(list), "table")
luaunit.assertEquals(#list, 31, "Should have exactly 31 easing functions")
-- Check that linear is in the list
local hasLinear = false
for _, name in ipairs(list) do
if name == "linear" then
hasLinear = true
break
end
end
luaunit.assertTrue(hasLinear, "List should contain 'linear'")
end
-- Test Easing.get() method
function TestEasing:testGet()
local linear = Easing.get("linear")
luaunit.assertNotNil(linear)
luaunit.assertEquals(type(linear), "function")
luaunit.assertEquals(linear(0.5), 0.5)
-- Test non-existent easing
local nonExistent = Easing.get("nonExistentEasing")
luaunit.assertNil(nonExistent)
end
-- Test that all InOut easings are symmetric around 0.5 -- Test that all InOut easings are symmetric around 0.5
function TestEasing:testInOutSymmetry() function TestEasing:testInOutSymmetry()
local inOutEasings = { local inOutEasings = {