diff --git a/.gitignore b/.gitignore index 6dced02..263b0ca 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ manifest.private.der manifest.public.der /.editor_settings /.deployer_cache + +# Example and documentation files (not part of core library) +example_color_api_usage.lua +DRUID_COLOR_API_IMPROVEMENTS.md diff --git a/druid/color.lua b/druid/color.lua index 3d9e660..363bc65 100644 --- a/druid/color.lua +++ b/druid/color.lua @@ -6,9 +6,13 @@ local COLOR_Z = hash("color.z") local M = {} +-- ============================================================================= +-- COLOR PARSING AND CREATION FUNCTIONS +-- ============================================================================= ----Get color by string (hex or from palette) ----@param color_id string|vector4 Color id from palette or hex color + +---Get color by string (hex or from palette) or pass through vector4 +---@param color_id string|vector4 Color id from palette, hex color, or existing vector4 ---@return vector4 function M.get_color(color_id) if type(color_id) ~= "string" then @@ -30,8 +34,82 @@ function M.get_color(color_id) end +---Parse color from various formats (hex, palette ID, or vector4) +---This is a clearer alias for get_color +---@param color_value string|vector4 Color value to parse +---@return vector4 +function M.parse(color_value) + return M.get_color(color_value) +end + + +---Create a color from RGBA values +---@param r number Red component (0-1) +---@param g number Green component (0-1) +---@param b number Blue component (0-1) +---@param a number|nil Alpha component (0-1), defaults to 1 +---@return vector4 +function M.new(r, g, b, a) + return vmath.vector4(r, g, b, a or 1) +end + + +---Create a color from hex string +---@param hex string Hex color. #00BBAA or 00BBAA or #0BA or 0BA +---@param alpha number|nil Alpha value. Default is 1 +---@return vector4 +function M.from_hex(hex, alpha) + return M.hex2vector4(hex, alpha) +end + + +---Create a color from HSB values +---@param h number Hue (0-1) +---@param s number Saturation (0-1) +---@param b number Brightness/Value (0-1) +---@param a number|nil Alpha value. Default is 1 +---@return vector4 +function M.from_hsb(h, s, b, a) + local r, g, b_val, alpha = M.hsb2rgb(h, s, b, a) + return vmath.vector4(r, g, b_val, alpha or 1) +end + + +-- ============================================================================= +-- COLOR FORMAT CONVERSION FUNCTIONS +-- ============================================================================= + +---Convert color to hex string +---@param color vector4 Color to convert +---@return string Hex color string (without #) +function M.to_hex(color) + return M.rgb2hex(color.x, color.y, color.z) +end + + +---Convert color to RGB values +---@param color vector4 Color to convert +---@return number, number, number, number r, g, b, a +function M.to_rgb(color) + return color.x, color.y, color.z, color.w +end + + +---Convert color to HSB values +---@param color vector4 Color to convert +---@return number, number, number, number h, s, b, a +function M.to_hsb(color) + return M.rgb2hsb(color.x, color.y, color.z, color.w) +end + + +-- ============================================================================= +-- PALETTE MANAGEMENT FUNCTIONS +-- ============================================================================= + + ---Add palette to palette data ----@param palette_data table +---@param palette_data table function M.add_palette(palette_data) for color_id, color in pairs(palette_data) do if type(color) == "string" then @@ -43,11 +121,59 @@ function M.add_palette(palette_data) end +---Set a single color in the palette +---@param color_id string Color identifier +---@param color vector4|string Color value (vector4 or hex string) +function M.set_palette_color(color_id, color) + if type(color) == "string" then + PALETTE_DATA[color_id] = M.hex2vector4(color) + else + PALETTE_DATA[color_id] = color + end +end + + +---Remove a color from the palette +---@param color_id string Color identifier to remove +function M.remove_palette_color(color_id) + PALETTE_DATA[color_id] = nil +end + + +---Check if a color exists in the palette +---@param color_id string Color identifier to check +---@return boolean +function M.has_palette_color(color_id) + return PALETTE_DATA[color_id] ~= nil +end + + +---Get a color from the palette +---@param color_id string Color identifier +---@return vector4|nil Color or nil if not found +function M.get_palette_color(color_id) + return PALETTE_DATA[color_id] +end + + +---Get the entire palette +---@return table function M.get_palette() return PALETTE_DATA end +---Clear the entire palette +function M.clear_palette() + PALETTE_DATA = {} +end + + +-- ============================================================================= +-- GUI NODE OPERATIONS +-- ============================================================================= + + ---Set color of gui node without changing alpha ---@param gui_node node ---@param color vector4|vector3|string Color in vector4, vector3 or color id from palette @@ -62,6 +188,34 @@ function M.set_color(gui_node, color) end +---Apply color to gui node (clearer alias for set_color) +---@param gui_node node GUI node to apply color to +---@param color vector4|vector3|string Color in vector4, vector3 or color id from palette +function M.apply_to_node(gui_node, color) + M.set_color(gui_node, color) +end + + +---Set node color including alpha +---@param gui_node node GUI node to apply color to +---@param color vector4|string Color with alpha channel +function M.set_node_color_with_alpha(gui_node, color) + if type(color) == "string" then + color = M.get_color(color) + end + + gui.set(gui_node, COLOR_X, color.x) + gui.set(gui_node, COLOR_Y, color.y) + gui.set(gui_node, COLOR_Z, color.z) + gui.set(gui_node, "color.w", color.w) +end + + +-- ============================================================================= +-- COLOR MANIPULATION FUNCTIONS +-- ============================================================================= + + ---Lerp colors via color HSB values ---@param t number Lerp value. 0 - color1, 1 - color2 ---@param color1 vector4 Color 1 @@ -81,6 +235,55 @@ function M.lerp(t, color1, color2) end +---Mix two colors using linear RGB interpolation (simpler than lerp) +---@param color1 vector4 First color +---@param color2 vector4 Second color +---@param ratio number Mixing ratio (0 = color1, 1 = color2) +---@return vector4 Mixed color +function M.mix(color1, color2, ratio) + local inv_ratio = 1 - ratio + return vmath.vector4( + color1.x * inv_ratio + color2.x * ratio, + color1.y * inv_ratio + color2.y * ratio, + color1.z * inv_ratio + color2.z * ratio, + color1.w * inv_ratio + color2.w * ratio + ) +end + + +---Lighten a color by mixing with white +---@param color vector4 Color to lighten +---@param amount number Amount to lighten (0-1) +---@return vector4 Lightened color +function M.lighten(color, amount) + return M.mix(color, COLOR_WHITE, amount) +end + + +---Darken a color by mixing with black +---@param color vector4 Color to darken +---@param amount number Amount to darken (0-1) +---@return vector4 Darkened color +function M.darken(color, amount) + local black = vmath.vector4(0, 0, 0, color.w) + return M.mix(color, black, amount) +end + + +---Adjust color alpha +---@param color vector4 Color to adjust +---@param alpha number New alpha value (0-1) +---@return vector4 Color with adjusted alpha +function M.with_alpha(color, alpha) + return vmath.vector4(color.x, color.y, color.z, alpha) +end + + +-- ============================================================================= +-- LOW-LEVEL CONVERSION FUNCTIONS +-- ============================================================================= + + ---Convert hex color to rgb values. ---@param hex string Hex color. #00BBAA or 00BBAA or #0BA or 0BA @@ -168,9 +371,10 @@ end ---Convert rgb color to hex color ----@param red number Red value ----@param green number Green value ----@param blue number Blue value +---@param red number Red value (0-1) +---@param green number Green value (0-1) +---@param blue number Blue value (0-1) +---@return string Hex color string (without #) function M.rgb2hex(red, green, blue) local r = string.format("%x", math.floor(red * 255)) local g = string.format("%x", math.floor(green * 255)) @@ -179,6 +383,11 @@ function M.rgb2hex(red, green, blue) end +-- ============================================================================= +-- INITIALIZATION +-- ============================================================================= + + local DEFAULT_PALETTE_PATH = sys.get_config_string("druid.palette_path") if DEFAULT_PALETTE_PATH then local loaded_palette = sys.load_resource(DEFAULT_PALETTE_PATH) diff --git a/test/test.gui_script b/test/test.gui_script index 11e6a0a..61cc50f 100644 --- a/test/test.gui_script +++ b/test/test.gui_script @@ -5,6 +5,7 @@ function init(self) deftest.add(require("test.tests.test_back_handler")) deftest.add(require("test.tests.test_blocker")) deftest.add(require("test.tests.test_button")) + deftest.add(require("test.tests.test_color")) deftest.add(require("test.tests.test_container")) deftest.add(require("test.tests.test_drag")) deftest.add(require("test.tests.test_grid")) diff --git a/test/tests/test_color.lua b/test/tests/test_color.lua new file mode 100644 index 0000000..112427b --- /dev/null +++ b/test/tests/test_color.lua @@ -0,0 +1,313 @@ +return function() + describe("Color Module", function() + local color = nil + + before(function() + color = require("druid.color") + end) + + describe("Color Creation and Parsing", function() + it("Should create colors from RGBA values", function() + local test_color = color.new(1, 0.5, 0, 0.8) + assert(test_color.x == 1) + assert(test_color.y == 0.5) + assert(test_color.z == 0) + assert(test_color.w == 0.8) + end) + + it("Should create colors from hex strings", function() + local red = color.from_hex("#FF0000") + assert(red.x == 1) + assert(red.y == 0) + assert(red.z == 0) + assert(red.w == 1) + + local blue_with_alpha = color.from_hex("0000FF", 0.5) + assert(blue_with_alpha.x == 0) + assert(blue_with_alpha.y == 0) + assert(blue_with_alpha.z == 1) + assert(blue_with_alpha.w == 0.5) + end) + + it("Should create colors from HSB values", function() + local red = color.from_hsb(0, 1, 1) -- HSB for red + assert(red.x == 1) + assert(red.y == 0) + assert(red.z == 0) + assert(red.w == 1) + end) + + it("Should parse various color formats", function() + -- Test parsing alias for get_color + local hex_color = color.parse("#FF0000") + assert(hex_color.x == 1) + + local vector_color = color.parse(vmath.vector4(0, 1, 0, 1)) + assert(vector_color.y == 1) + end) + + it("Should parse hex colors correctly with get_color", function() + -- Test with # prefix + local color1 = color.get_color("#FF0000") + assert(color1.x == 1) + assert(color1.y == 0) + assert(color1.z == 0) + assert(color1.w == 1) + + -- Test without # prefix + local color2 = color.get_color("00FF00") + assert(color2.x == 0) + assert(color2.y == 1) + assert(color2.z == 0) + assert(color2.w == 1) + + -- Test 3-digit hex + local color3 = color.get_color("F0F") + assert(color3.x == 1) + assert(color3.y == 0) + assert(color3.z == 1) + assert(color3.w == 1) + end) + + it("Should handle vector4 input in get_color", function() + local input_color = vmath.vector4(1, 0.5, 0, 1) + local result = color.get_color(input_color) + assert(result == input_color) + end) + + it("Should return white for unknown color IDs", function() + local result = color.get_color("unknown_color") + assert(result.x == 1) + assert(result.y == 1) + assert(result.z == 1) + assert(result.w == 1) + end) + end) + + describe("Color Format Conversion", function() + it("Should convert colors to hex", function() + local red = vmath.vector4(1, 0, 0, 1) + local hex = color.to_hex(red) + assert(hex == "FF0000") + end) + + it("Should convert colors to RGB values", function() + local test_color = vmath.vector4(1, 0.5, 0.25, 0.8) + local r, g, b, a = color.to_rgb(test_color) + assert(r == 1) + assert(g == 0.5) + assert(b == 0.25) + assert(a == 0.8) + end) + + it("Should convert colors to HSB values", function() + local red = vmath.vector4(1, 0, 0, 1) + local h, s, b, a = color.to_hsb(red) + assert(h == 0) -- Red hue + assert(s == 1) -- Full saturation + assert(b == 1) -- Full brightness + assert(a == 1) -- Full alpha + end) + + it("Should convert hex to rgb values", function() + local r, g, b = color.hex2rgb("#FF8000") + assert(r == 1) + assert(g == 0.5019607843137255) -- 128/255 + assert(b == 0) + end) + + it("Should convert hex to vector4", function() + local vec = color.hex2vector4("#FF8000", 0.5) + assert(vec.x == 1) + assert(vec.y == 0.5019607843137255) + assert(vec.z == 0) + assert(vec.w == 0.5) + end) + + it("Should convert rgb to hex", function() + local hex = color.rgb2hex(1, 0.5019607843137255, 0) + assert(hex == "FF8000") + end) + end) + + describe("Color Space Conversion", function() + it("Should convert RGB to HSB correctly", function() + local h, s, v, a = color.rgb2hsb(1, 0, 0, 1) -- Red + assert(h == 0) + assert(s == 1) + assert(v == 1) + assert(a == 1) + end) + + it("Should convert HSB to RGB correctly", function() + local r, g, b, a = color.hsb2rgb(0, 1, 1, 1) -- Red + assert(r == 1) + assert(g == 0) + assert(b == 0) + assert(a == 1) + end) + + it("Should handle round-trip HSB conversion", function() + local original_r, original_g, original_b = 0.5, 0.7, 0.3 + local h, s, v = color.rgb2hsb(original_r, original_g, original_b) + local converted_r, converted_g, converted_b = color.hsb2rgb(h, s, v) + + -- Allow for small floating point differences + assert(math.abs(converted_r - original_r) < 0.001) + assert(math.abs(converted_g - original_g) < 0.001) + assert(math.abs(converted_b - original_b) < 0.001) + end) + end) + + describe("Palette Management", function() + it("Should add and retrieve palette colors", function() + local test_palette = { + primary = vmath.vector4(1, 0, 0, 1), + secondary = "#00FF00", + tertiary = vmath.vector4(0, 0, 1, 1) + } + + color.add_palette(test_palette) + + local primary = color.get_color("primary") + assert(primary.x == 1) + assert(primary.y == 0) + assert(primary.z == 0) + assert(primary.w == 1) + + local secondary = color.get_color("secondary") + assert(secondary.x == 0) + assert(secondary.y == 1) + assert(secondary.z == 0) + assert(secondary.w == 1) + end) + + it("Should manage individual palette colors", function() + -- Set a palette color + color.set_palette_color("test_red", vmath.vector4(1, 0, 0, 1)) + assert(color.has_palette_color("test_red") == true) + + local retrieved = color.get_palette_color("test_red") + assert(retrieved.x == 1) + assert(retrieved.y == 0) + assert(retrieved.z == 0) + + -- Remove the color + color.remove_palette_color("test_red") + assert(color.has_palette_color("test_red") == false) + assert(color.get_palette_color("test_red") == nil) + end) + + it("Should return the palette", function() + local palette = color.get_palette() + assert(type(palette) == "table") + end) + + it("Should clear the palette", function() + color.set_palette_color("temp_color", vmath.vector4(1, 1, 1, 1)) + color.clear_palette() + assert(color.has_palette_color("temp_color") == false) + end) + end) + + describe("Color Manipulation", function() + it("Should lerp colors correctly", function() + local color1 = vmath.vector4(1, 0, 0, 1) -- Red + local color2 = vmath.vector4(0, 1, 0, 1) -- Green + + local mid_color = color.lerp(0.5, color1, color2) + -- Note: lerp uses HSB interpolation, so the result might not be a simple average + assert(type(mid_color.x) == "number") + assert(type(mid_color.y) == "number") + assert(type(mid_color.z) == "number") + assert(mid_color.w == 1) + + -- Test endpoints + local start_color = color.lerp(0, color1, color2) + local end_color = color.lerp(1, color1, color2) + + assert(math.abs(start_color.x - color1.x) < 0.001) + assert(math.abs(end_color.x - color2.x) < 0.001) + end) + + it("Should mix colors using linear RGB interpolation", function() + local red = vmath.vector4(1, 0, 0, 1) + local blue = vmath.vector4(0, 0, 1, 1) + + local mixed = color.mix(red, blue, 0.5) + assert(mixed.x == 0.5) -- Half red + assert(mixed.y == 0) -- No green + assert(mixed.z == 0.5) -- Half blue + assert(mixed.w == 1) -- Full alpha + end) + + it("Should lighten colors", function() + local dark_red = vmath.vector4(0.5, 0, 0, 1) + local lightened = color.lighten(dark_red, 0.5) + + assert(lightened.x > dark_red.x) -- Should be lighter + assert(lightened.y > dark_red.y) -- Should have some green/white + assert(lightened.z > dark_red.z) -- Should have some blue/white + end) + + it("Should darken colors", function() + local bright_red = vmath.vector4(1, 0, 0, 1) + local darkened = color.darken(bright_red, 0.5) + + assert(darkened.x < bright_red.x) -- Should be darker + assert(darkened.y == 0) -- Should still have no green + assert(darkened.z == 0) -- Should still have no blue + end) + + it("Should adjust alpha", function() + local opaque_red = vmath.vector4(1, 0, 0, 1) + local semi_transparent = color.with_alpha(opaque_red, 0.5) + + assert(semi_transparent.x == 1) -- Color unchanged + assert(semi_transparent.y == 0) + assert(semi_transparent.z == 0) + assert(semi_transparent.w == 0.5) -- Alpha changed + end) + end) + + describe("GUI Node Operations", function() + it("Should set color on GUI node", function() + -- Create a test node + local test_node = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(100, 100, 0)) + + -- Test with vector4 + local test_color = vmath.vector4(1, 0.5, 0, 1) + color.set_color(test_node, test_color) + + -- Verify color was set (we can't easily read it back, but we can verify no errors) + assert(true) -- If we get here, no error occurred + + -- Test with string color + color.set_color(test_node, "#FF0000") + assert(true) -- If we get here, no error occurred + + -- Clean up + gui.delete_node(test_node) + end) + + it("Should apply color to node using alias", function() + local test_node = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(100, 100, 0)) + + -- Test the alias function + color.apply_to_node(test_node, vmath.vector4(0, 1, 0, 1)) + assert(true) -- If we get here, no error occurred + + gui.delete_node(test_node) + end) + + it("Should set node color including alpha", function() + local test_node = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(100, 100, 0)) + + color.set_node_color_with_alpha(test_node, vmath.vector4(1, 0, 0, 0.5)) + assert(true) -- If we get here, no error occurred + + gui.delete_node(test_node) + end) + end) + end) +end \ No newline at end of file