461 lines
13 KiB
Lua
461 lines
13 KiB
Lua
local ErrorHandler = nil
|
|
|
|
--- Initialize ErrorHandler dependency
|
|
---@param errorHandler table The ErrorHandler module
|
|
local function initializeErrorHandler(errorHandler)
|
|
ErrorHandler = errorHandler
|
|
end
|
|
|
|
--- Standardized error message formatter (fallback for when ErrorHandler not available)
|
|
---@param module string -- Module name (e.g., "Color", "Theme", "Units")
|
|
---@param message string
|
|
---@return string
|
|
local function formatError(module, message)
|
|
return string.format("[FlexLove.%s] %s", module, message)
|
|
end
|
|
|
|
-- 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
|
|
---@field r number -- Red component (0-1)
|
|
---@field g number -- Green component (0-1)
|
|
---@field b number -- Blue component (0-1)
|
|
---@field a number -- Alpha component (0-1)
|
|
local Color = {}
|
|
Color.__index = Color
|
|
|
|
--- Create a new color instance
|
|
---@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)
|
|
local self = setmetatable({}, Color)
|
|
|
|
-- 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)
|
|
|
|
self.r = sanitizedR or 0
|
|
self.g = sanitizedG or 0
|
|
self.b = sanitizedB or 0
|
|
self.a = sanitizedA or 1
|
|
return self
|
|
end
|
|
|
|
---Convert color to RGBA components
|
|
---@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
|
|
|
|
--- Convert hex string to color
|
|
--- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats
|
|
---@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
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
|
|
input = tostring(hexWithTag),
|
|
issue = "not a string",
|
|
fallback = "white (#FFFFFF)"
|
|
})
|
|
end
|
|
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
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
|
|
input = hexWithTag,
|
|
issue = "invalid hex digits",
|
|
fallback = "white (#FFFFFF)"
|
|
})
|
|
end
|
|
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
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
|
|
input = hexWithTag,
|
|
issue = "invalid hex digits",
|
|
fallback = "white (#FFFFFFFF)"
|
|
})
|
|
end
|
|
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
|
end
|
|
return Color.new(r / 255, g / 255, b / 255, a / 255)
|
|
else
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
|
|
input = hexWithTag,
|
|
expected = "#RRGGBB or #RRGGBBAA",
|
|
hexLength = #hex,
|
|
fallback = "white (#FFFFFF)"
|
|
})
|
|
end
|
|
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
|
end
|
|
end
|
|
|
|
--- Validate a single color channel value
|
|
---@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
|
|
|
|
--- 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
|
|
---@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
|
|
|
|
-- Check for named color
|
|
if NAMED_COLORS[value:lower()] then
|
|
return "named"
|
|
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
|
|
|
|
--- Validate a color value
|
|
---@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
|
|
|
|
--- Sanitize a color value (always returns a valid Color)
|
|
---@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
|
|
|
|
-- 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
|
|
-- 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
|
|
|
|
--- Parse a color from various formats (always returns a valid Color)
|
|
---@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
|
|
|
|
--- Linear interpolation between two colors
|
|
---@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 r = colorA.r * (1 - t) + colorB.r * t
|
|
local g = colorA.g * (1 - t) + colorB.g * t
|
|
local b = colorA.b * (1 - t) + colorB.b * t
|
|
local a = colorA.a * (1 - t) + colorB.a * t
|
|
|
|
return Color.new(r, g, b, a)
|
|
end
|
|
|
|
-- Export ErrorHandler initializer
|
|
Color.initializeErrorHandler = initializeErrorHandler
|
|
|
|
return Color
|