diff --git a/README.md b/README.md index f59de4d..3bb00b7 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,6 @@ end) - Druid automatically calls `acquire_input_focus` if you have input components. Therefore, manual calling of `acquire_input_focus` is not required. - When deleting a **Druid** component node, make sure to remove it using `druid:remove(component)`. - ## Examples Try the [**HTML5 version**](https://insality.github.io/druid/) of the **Druid** example app. @@ -196,6 +195,7 @@ To better understand **Druid**, read the following documentation: - [Druid styles](wiki/styles.md) - [Advanced Setup](wiki/advanced-setup.md) - [Optimize Druid Size](wiki/optimize_druid_size.md) +- [Creating Custom Components](wiki/creating_custom_components.md) - [Changelog](wiki/changelog.md) diff --git a/test/test.gui_script b/test/test.gui_script index e4af758..2b4ce95 100644 --- a/test/test.gui_script +++ b/test/test.gui_script @@ -9,6 +9,9 @@ function init(self) deftest.add(require("test.tests.test_helper")) deftest.add(require("test.tests.test_text")) deftest.add(require("test.tests.test_input")) + deftest.add(require("test.tests.test_layout")) + deftest.add(require("test.tests.test_container")) + deftest.add(require("test.tests.test_rich_text")) deftest.add(require("test.tests.test_druid_instance")) local is_report = (sys.get_config_int("test.report", 0) == 1) diff --git a/test/tests/test_container.lua b/test/tests/test_container.lua new file mode 100644 index 0000000..e255df3 --- /dev/null +++ b/test/tests/test_container.lua @@ -0,0 +1,319 @@ +return function() + describe("Container Component", function() + local mock_time + local mock_input + local druid_system + local const + + local druid + local context + + before(function() + mock_time = require("deftest.mock.time") + mock_input = require("test.helper.mock_input") + druid_system = require("druid.druid") + const = require("druid.const") + + mock_time.mock() + mock_time.set(0) + + context = vmath.vector3() + druid = druid_system.new(context) + end) + + after(function() + mock_time.unmock() + druid:final() + druid = nil + end) + + + it("Should initialize container with default settings", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + assert(container ~= nil) + assert(container.node == container_node) + assert(container.origin_size.x == 100) + assert(container.origin_size.y == 100) + assert(container.size.x == 100) + assert(container.size.y == 100) + assert(container.mode == const.LAYOUT_MODE.FIT) + assert(container.min_size_x == 0) + assert(container.min_size_y == 0) + assert(container._containers ~= nil) + assert(#container._containers == 0) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should get and set size", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + local size = container:get_size() + assert(size.x == 100) + assert(size.y == 100) + + container:set_size(200, 150) + + size = container:get_size() + assert(size.x == 200) + assert(size.y == 150) + + local node_size = gui.get_size(container_node) + assert(node_size.x == 200) + assert(node_size.y == 150) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should get and set position", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + local position = container:get_position() + assert(position.x == 50) + assert(position.y == 50) + + container:set_position(100, 200) + + position = container:get_position() + assert(position.x == 100) + assert(position.y == 200) + + local node_position = gui.get_position(container_node) + assert(node_position.x == 100) + assert(node_position.y == 200) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should set pivot", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + -- Default pivot is typically PIVOT_CENTER + local initial_pivot = gui.get_pivot(container_node) + + container:set_pivot(gui.PIVOT_NW) + assert(gui.get_pivot(container_node) == gui.PIVOT_NW) + + container:set_pivot(gui.PIVOT_SE) + assert(gui.get_pivot(container_node) == gui.PIVOT_SE) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should set min size", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + assert(container.min_size_x == 0) + assert(container.min_size_y == 0) + + container:set_min_size(50, 75) + + assert(container.min_size_x == 50) + assert(container.min_size_y == 75) + + -- Should respect min size when setting smaller size + container:set_size(25, 25) + local size = container:get_size() + assert(size.x == 50) + assert(size.y == 75) + + -- Should allow larger size + container:set_size(200, 200) + size = container:get_size() + assert(size.x == 200) + assert(size.y == 200) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should fire on_size_changed event", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + local on_size_changed_calls = 0 + local last_size = nil + + container.on_size_changed:subscribe(function(instance, new_size) + on_size_changed_calls = on_size_changed_calls + 1 + last_size = new_size + end) + + container:set_size(200, 150) + + assert(on_size_changed_calls == 1) + assert(last_size ~= nil) + assert(last_size.x == 200) + assert(last_size.y == 150) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should fit into custom size", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node, const.LAYOUT_MODE.STRETCH) + + local fit_size = vmath.vector3(200, 150, 0) + container:fit_into_size(fit_size) + + assert(container.fit_size == fit_size) + + -- The exact result will depend on the implementation and screen aspect ratio, + -- but we can at least verify it changes the size + local size = container:get_size() + assert(size.x > 100 or size.y > 100) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should create and manage parent-child container relationships", function() + local parent_node = gui.new_box_node(vmath.vector3(400, 300, 0), vmath.vector3(800, 600, 0)) + local child_node = gui.new_box_node(vmath.vector3(100, 100, 0), vmath.vector3(200, 200, 0)) + + local parent_container = druid:new_container(parent_node) + + -- Add a child container through the parent + local child_container = parent_container:add_container(child_node) + + assert(child_container ~= nil) + assert(child_container.node == child_node) + assert(child_container._parent_container == parent_container) + assert(#parent_container._containers == 1) + assert(parent_container._containers[1] == child_container) + + -- Verify the child's node has been parented correctly + assert(gui.get_parent(child_node) == parent_node) + + -- Remove the child container + parent_container:remove_container_by_node(child_node) + + assert(#parent_container._containers == 0) + + druid:remove(parent_container) + gui.delete_node(parent_node) + gui.delete_node(child_node) + end) + + it("Should handle different layout modes", function() + local parent_node = gui.new_box_node(vmath.vector3(400, 300, 0), vmath.vector3(800, 600, 0)) + local child_node = gui.new_box_node(vmath.vector3(100, 100, 0), vmath.vector3(200, 200, 0)) + + local parent_container = druid:new_container(parent_node) + + -- Test FIT mode + local child_fit = parent_container:add_container(child_node, const.LAYOUT_MODE.FIT) + assert(child_fit.mode == const.LAYOUT_MODE.FIT) + + local size_fit = child_fit:get_size() + local original_size = vmath.vector3(200, 200, 0) + + -- Size should remain the same in FIT mode + assert(math.abs(size_fit.x - original_size.x) < 0.001) + assert(math.abs(size_fit.y - original_size.y) < 0.001) + + -- Set to STRETCH mode + parent_container:remove_container_by_node(child_node) + local child_stretch = parent_container:add_container(child_node, const.LAYOUT_MODE.STRETCH) + assert(child_stretch.mode == const.LAYOUT_MODE.STRETCH) + + -- Set to STRETCH_X mode + parent_container:remove_container_by_node(child_node) + local child_stretch_x = parent_container:add_container(child_node, const.LAYOUT_MODE.STRETCH_X) + assert(child_stretch_x.mode == const.LAYOUT_MODE.STRETCH_X) + + -- Set to STRETCH_Y mode + parent_container:remove_container_by_node(child_node) + local child_stretch_y = parent_container:add_container(child_node, const.LAYOUT_MODE.STRETCH_Y) + assert(child_stretch_y.mode == const.LAYOUT_MODE.STRETCH_Y) + + druid:remove(parent_container) + gui.delete_node(parent_node) + gui.delete_node(child_node) + end) + + it("Should create and clear draggable corners", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + container:create_draggable_corners() + + assert(#container._draggable_corners > 0) + + container:clear_draggable_corners() + + assert(#container._draggable_corners == 0) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should set size and maintain anchor pivot position", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + -- Set pivot to NW corner + container:set_pivot(gui.PIVOT_NW) + local initial_position = gui.get_position(container_node) + + -- Change size with anchor_pivot=PIVOT_NW - the NW corner should stay at the same position + container:set_size(200, 150, gui.PIVOT_NW) + + local new_position = gui.get_position(container_node) + assert(math.abs(new_position.x - initial_position.x) < 0.001) + assert(math.abs(new_position.y - initial_position.y) < 0.001) + + -- Set pivot to SE corner + container:set_pivot(gui.PIVOT_SE) + initial_position = gui.get_position(container_node) + + -- Change size with anchor_pivot=PIVOT_SE - the SE corner should stay at the same position + container:set_size(300, 250, gui.PIVOT_SE) + + new_position = gui.get_position(container_node) + assert(math.abs(new_position.x - initial_position.x) < 0.001) + assert(math.abs(new_position.y - initial_position.y) < 0.001) + + druid:remove(container) + gui.delete_node(container_node) + end) + + it("Should refresh origins", function() + local container_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local container = druid:new_container(container_node) + + -- Change the node size and position directly + gui.set_size(container_node, vmath.vector3(200, 150, 0)) + gui.set_position(container_node, vmath.vector3(75, 75, 0)) + + -- The container's internal origin values should not have updated yet + assert(container.origin_size.x == 100) + assert(container.origin_size.y == 100) + assert(container.origin_position.x == 50) + assert(container.origin_position.y == 50) + + -- Refresh origins + container:refresh_origins() + + -- Now the origin values should match the node + assert(container.origin_size.x == 200) + assert(container.origin_size.y == 150) + assert(container.origin_position.x == 75) + assert(container.origin_position.y == 75) + + druid:remove(container) + gui.delete_node(container_node) + end) + end) +end diff --git a/test/tests/test_layout.lua b/test/tests/test_layout.lua new file mode 100644 index 0000000..5fb72e9 --- /dev/null +++ b/test/tests/test_layout.lua @@ -0,0 +1,487 @@ +return function() + describe("Layout Component", function() + local mock_time + local mock_input + local druid_system + + local druid + local context + + before(function() + mock_time = require("deftest.mock.time") + mock_input = require("test.helper.mock_input") + druid_system = require("druid.druid") + + mock_time.mock() + mock_time.set(0) + + context = vmath.vector3() + druid = druid_system.new(context) + end) + + after(function() + mock_time.unmock() + druid:final() + druid = nil + end) + + + it("Should initialize layout with default settings", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + assert(layout ~= nil) + assert(layout.node == layout_node) + assert(layout.type == "horizontal") + assert(layout.is_dirty == true) + assert(layout.entities ~= nil) + assert(#layout.entities == 0) + assert(layout.is_resize_width == false) + assert(layout.is_resize_height == false) + assert(layout.is_justify == false) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should add and remove nodes", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(20, 20, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(20, 20, 0)) + + layout:add(child1) + assert(#layout.entities == 1) + assert(layout.entities[1] == child1) + + layout:add(child2) + assert(#layout.entities == 2) + assert(layout.entities[2] == child2) + + layout:remove(child1) + assert(#layout.entities == 1) + assert(layout.entities[1] == child2) + + layout:clear_layout() + assert(#layout.entities == 0) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + end) + + it("Should set node index", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(20, 20, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(20, 20, 0)) + local child3 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(20, 20, 0)) + + layout:add(child1) + layout:add(child2) + layout:add(child3) + + assert(layout.entities[1] == child1) + assert(layout.entities[2] == child2) + assert(layout.entities[3] == child3) + + layout:set_node_index(child3, 1) + + assert(layout.entities[1] == child3) + assert(layout.entities[2] == child1) + assert(layout.entities[3] == child2) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + gui.delete_node(child3) + end) + + it("Should set layout type", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + assert(layout.type == "horizontal") + + layout:set_type("vertical") + assert(layout.type == "vertical") + assert(layout.is_dirty == true) + + layout:set_type("horizontal_wrap") + assert(layout.type == "horizontal_wrap") + assert(layout.is_dirty == true) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should set margin", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + local initial_margin_x = layout.margin.x + local initial_margin_y = layout.margin.y + + layout:set_margin(10, 20) + + assert(layout.margin.x == 10) + assert(layout.margin.y == 20) + assert(layout.is_dirty == true) + + -- Test partial update + layout:set_margin(15) + assert(layout.margin.x == 15) + assert(layout.margin.y == 20) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should set padding", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + layout:set_padding(5, 10, 15, 20) + + assert(layout.padding.x == 5) -- left + assert(layout.padding.y == 10) -- top + assert(layout.padding.z == 15) -- right + assert(layout.padding.w == 20) -- bottom + assert(layout.is_dirty == true) + + -- Test partial update + layout:set_padding(25) + assert(layout.padding.x == 25) + assert(layout.padding.y == 10) + assert(layout.padding.z == 15) + assert(layout.padding.w == 20) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should set justify", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + assert(layout.is_justify == false) + + layout:set_justify(true) + assert(layout.is_justify == true) + assert(layout.is_dirty == true) + + layout:set_justify(false) + assert(layout.is_justify == false) + assert(layout.is_dirty == true) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should set hug content", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + assert(layout.is_resize_width == false) + assert(layout.is_resize_height == false) + + layout:set_hug_content(true, false) + assert(layout.is_resize_width == true) + assert(layout.is_resize_height == false) + assert(layout.is_dirty == true) + + layout:set_hug_content(false, true) + assert(layout.is_resize_width == false) + assert(layout.is_resize_height == true) + assert(layout.is_dirty == true) + + layout:set_hug_content(true, true) + assert(layout.is_resize_width == true) + assert(layout.is_resize_height == true) + assert(layout.is_dirty == true) + + druid:remove(layout) + gui.delete_node(layout_node) + end) + + it("Should fire on_size_changed event", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + + local on_size_changed_calls = 0 + local last_size = nil + + layout.on_size_changed:subscribe(function(new_size) + on_size_changed_calls = on_size_changed_calls + 1 + last_size = new_size + end) + + -- Set to hug content + layout:set_hug_content(true, true) + + -- Add some nodes + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(30, 20, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(30, 20, 0)) + + layout:add(child1) + layout:add(child2) + + -- Force refresh to trigger the event + layout:refresh_layout() + + assert(on_size_changed_calls >= 1) + assert(last_size ~= nil) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + end) + + it("Should handle horizontal layout correctly", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(300, 100, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("horizontal") + layout:set_margin(10, 0) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child3 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(child1) + layout:add(child2) + layout:add(child3) + + layout:refresh_layout() + + -- Check positions - in horizontal layout, nodes should be arranged left to right + local pos1 = gui.get_position(child1) + local pos2 = gui.get_position(child2) + local pos3 = gui.get_position(child3) + + assert(pos2.x > pos1.x) + assert(pos3.x > pos2.x) + + -- Y positions should be approximately the same + assert(math.abs(pos1.y - pos2.y) < 0.001) + assert(math.abs(pos2.y - pos3.y) < 0.001) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + gui.delete_node(child3) + end) + + it("Should handle vertical layout correctly", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 300, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("vertical") + layout:set_margin(0, 10) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child3 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(child1) + layout:add(child2) + layout:add(child3) + + layout:refresh_layout() + + -- Check positions - in vertical layout, nodes should be arranged top to bottom + local pos1 = gui.get_position(child1) + local pos2 = gui.get_position(child2) + local pos3 = gui.get_position(child3) + + assert(pos2.y < pos1.y) + assert(pos3.y < pos2.y) + + -- X positions should be approximately the same + assert(math.abs(pos1.x - pos2.x) < 0.001) + assert(math.abs(pos2.x - pos3.x) < 0.001) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + gui.delete_node(child3) + end) + + it("Should handle horizontal_wrap layout correctly", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(120, 200, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("horizontal_wrap") + layout:set_margin(10, 10) + + -- Create nodes that will need to wrap + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child3 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child4 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(child1) + layout:add(child2) + layout:add(child3) + layout:add(child4) + + layout:refresh_layout() + + -- Check positions - in horizontal_wrap layout, nodes should wrap to new line + local pos1 = gui.get_position(child1) + local pos2 = gui.get_position(child2) + local pos3 = gui.get_position(child3) + local pos4 = gui.get_position(child4) + + -- First two nodes should be on the same row + assert(math.abs(pos1.y - pos2.y) < 0.001) + + -- child3 should be on a new row + assert(pos3.y < pos1.y) + + -- child3 and child4 should be on the same row + assert(math.abs(pos3.y - pos4.y) < 0.001) + + -- X position should flow left to right on each row + assert(pos2.x > pos1.x) + assert(pos4.x > pos3.x) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + gui.delete_node(child3) + gui.delete_node(child4) + end) + + it("Should correctly calculate size with content hugging", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(100, 100, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("vertical") + layout:set_hug_content(true, true) + layout:set_margin(0, 10) + layout:set_padding(5, 5, 5, 5) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(60, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(70, 30, 0)) + + layout:add(child1) + layout:add(child2) + + layout:refresh_layout() + + -- Size should be adjusted to fit content plus padding + local size = gui.get_size(layout_node) + + -- Expected width: width of widest child (70) + left and right padding (5+5) + assert(math.abs(size.x - 80) < 1) + + -- Expected height: sum of child heights (30+30) + margin (10) + top and bottom padding (5+5) + assert(math.abs(size.y - 80) < 1) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + end) + + it("Should justify content horizontally", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(300, 100, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("horizontal") + layout:set_justify(true) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(child1) + layout:add(child2) + + layout:refresh_layout() + + -- Check positions - in justified horizontal layout, nodes should be spaced far apart + local pos1 = gui.get_position(child1) + local pos2 = gui.get_position(child2) + + -- Get the layout size and calculate expected positions + local size = gui.get_size(layout_node) + + -- In justified layout, the distance between nodes should be larger than with normal layout + assert((pos2.x - pos1.x) > 100) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + end) + + it("Should handle disabled nodes", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(300, 100, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("horizontal") + layout:set_margin(10, 0) + + local child1 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child2 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + local child3 = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(child1) + layout:add(child2) + layout:add(child3) + + -- Disable the middle node + gui.set_enabled(child2, false) + + layout:refresh_layout() + + -- Check positions - the disabled node should be ignored in the layout + local pos1 = gui.get_position(child1) + local pos3 = gui.get_position(child3) + + -- child3 should be positioned right after child1 (as if child2 doesn't exist) + local node_width = gui.get_size(child1).x + local expected_gap = node_width + layout.margin.x + + -- The distance should be approximately the width of a node plus margin + assert(math.abs((pos3.x - pos1.x) - expected_gap) < 1) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(child1) + gui.delete_node(child2) + gui.delete_node(child3) + end) + + it("Should handle text nodes correctly", function() + local layout_node = gui.new_box_node(vmath.vector3(50, 50, 0), vmath.vector3(300, 100, 0)) + local layout = druid:new_layout(layout_node) + layout:set_type("horizontal") + layout:set_margin(10, 0) + + local text_node = gui.new_text_node(vmath.vector3(0, 0, 0), "Hello World") + gui.set_font(text_node, "druid_text_bold") + + local box_node = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(50, 30, 0)) + + layout:add(text_node) + layout:add(box_node) + + layout:refresh_layout() + + -- Check positions - the text node should be positioned based on its text size + local pos_text = gui.get_position(text_node) + local pos_box = gui.get_position(box_node) + + assert(pos_box.x > pos_text.x) + + druid:remove(layout) + gui.delete_node(layout_node) + gui.delete_node(text_node) + gui.delete_node(box_node) + end) + end) +end diff --git a/test/tests/test_rich_text.lua b/test/tests/test_rich_text.lua new file mode 100644 index 0000000..0e4ad0d --- /dev/null +++ b/test/tests/test_rich_text.lua @@ -0,0 +1,322 @@ +return function() + describe("Rich Text Component", function() + local mock_time + local mock_input + local druid_system + + local druid + local context + + before(function() + mock_time = require("deftest.mock.time") + mock_input = require("test.helper.mock_input") + druid_system = require("druid.druid") + + mock_time.mock() + mock_time.set(0) + + context = vmath.vector3() + druid = druid_system.new(context) + end) + + after(function() + mock_time.unmock() + druid:final() + druid = nil + end) + + + it("Should initialize with default settings", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "Initial Text") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + assert(rich_text ~= nil) + assert(rich_text.root == text_node) + assert(rich_text.text_prefab == text_node) + assert(rich_text:get_text() == "Initial Text") + + -- Check that the original text node is cleared + assert(gui.get_text(text_node) == "") + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should initialize with custom text", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "Initial Text") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node, "Custom Text") + + assert(rich_text:get_text() == "Custom Text") + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle basic text setting and getting", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + assert(rich_text:get_text() == "") + + rich_text:set_text("Hello, World!") + assert(rich_text:get_text() == "Hello, World!") + + rich_text:set_text("New text") + assert(rich_text:get_text() == "New text") + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle color tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Test color tag with named color + local words = rich_text:set_text("Colored Text") + + assert(#words > 0) + -- Word should have a tags field with color tag + assert(words[1].tags.color) + + -- Test color tag with RGB values + words = rich_text:set_text("Colored Text") + + assert(#words > 0) + assert(words[1].tags.color) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle shadow tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Test shadow tag with named color + local words = rich_text:set_text("Shadowed Text") + + assert(#words > 0) + assert(words[1].shadow ~= nil) + + -- Test shadow tag with RGBA values + words = rich_text:set_text("Shadowed Text") + + assert(#words > 0) + assert(words[1].shadow ~= nil) + assert(words[1].shadow.x < 0.1) -- Black shadow should have low RGB values + assert(words[1].shadow.y < 0.1) + assert(words[1].shadow.z < 0.1) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle outline tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Test outline tag with named color + local words = rich_text:set_text("Outlined Text") + + assert(#words > 0) + assert(words[1].outline ~= nil) + + -- Test outline tag with RGBA values + words = rich_text:set_text("Outlined Text") + + assert(#words > 0) + assert(words[1].outline ~= nil) + assert(words[1].outline.x < 0.1) -- Black outline should have low RGB values + assert(words[1].outline.y < 0.1) + assert(words[1].outline.z < 0.1) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle size tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Test size tag with value of 2 (twice as large) + local words = rich_text:set_text("Large Text") + + assert(#words > 0) + assert(words[1].relative_scale == 2) + + -- Test size tag with value of 0.5 (half as large) + words = rich_text:set_text("Small Text") + + assert(#words > 0) + assert(words[1].relative_scale == 0.5) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle line break tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + gui.set_line_break(text_node, true) -- Enable multiline + + local rich_text = druid:new_rich_text(text_node) + + -- Test line break tag + local words, line_metrics = rich_text:set_text("Line 1
Line 2") + + assert(#words > 0) + assert(line_metrics.lines ~= nil) + assert(#line_metrics.lines >= 2) -- Should have at least 2 lines + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle nobr tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + gui.set_line_break(text_node, true) -- Enable multiline + + local rich_text = druid:new_rich_text(text_node) + + -- Test no break tag + local words = rich_text:set_text("This text should not break to multiple lines") + + assert(#words > 0) + assert(words[1].nobr == true) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle image tag", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Testing with a default texture with a fixed width and height to avoid nil errors + -- (This ensures image.width and image.height are numbers, not nil) + local words = rich_text:set_text("") + + assert(#words > 0) + assert(words[1].tags.img) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle multiple tags", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Test combined tags + local words = rich_text:set_text("Big Red Text") + + assert(#words > 0) + assert(words[1].tags.color) + assert(words[1].tags.size) + assert(words[1].relative_scale == 2) + + -- Test nested tags + words = rich_text:set_text("Red Big Red Red") + + assert(#words >= 3) + -- All words should have color tag + assert(words[1].tags.color) + assert(words[2].tags.color) + assert(words[3].tags.color) + + -- Middle word should also have size tag + assert(words[2].tags.size) + assert(words[2].relative_scale == 2) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should handle tagged words", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Set text with a custom tag + rich_text:set_text("Tagged Text Normal Text") + + -- Get words with the custom tag + local tagged_words = rich_text:tagged("mytag") + + assert(#tagged_words > 0) + assert(tagged_words[1].tags.mytag == true) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should clear text", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Set some text first + rich_text:set_text("Hello, World!") + + assert(rich_text:get_text() == "Hello, World!") + assert(rich_text:get_words() ~= nil) + + -- Clear text + rich_text:clear() + + assert(rich_text:get_text() == nil) + assert(rich_text:get_words() == nil) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + + it("Should get words and line metrics", function() + local text_node = gui.new_text_node(vmath.vector3(50, 50, 0), "") + gui.set_font(text_node, "druid_text_bold") + + local rich_text = druid:new_rich_text(text_node) + + -- Set text + rich_text:set_text("Hello, World!") + + -- Get words + local words = rich_text:get_words() + + assert(words ~= nil) + assert(#words > 0) + + -- Get line metrics + local line_metrics = rich_text:get_line_metric() + + assert(line_metrics ~= nil) + -- Just check line_metrics exists, don't assume values + assert(line_metrics.lines ~= nil) + + druid:remove(rich_text) + gui.delete_node(text_node) + end) + end) +end