From 1528e878644b51b05a63e906ef6cb355731d2681 Mon Sep 17 00:00:00 2001 From: Lazerbeak12345 <22641188+Lazerbeak12345@users.noreply.github.com> Date: Thu, 25 Jul 2024 01:40:18 -0600 Subject: [PATCH] feat: Embed support (#15) Co-authored-by: luk3yx --- .gitignore | 1 + embed.lua | 92 ++++++++++++++++++++ init.lua | 6 +- test.lua | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 embed.lua diff --git a/.gitignore b/.gitignore index 4883731..88e30ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ test-fs.lua *.old +luacov.*.out diff --git a/embed.lua b/embed.lua new file mode 100644 index 0000000..374573e --- /dev/null +++ b/embed.lua @@ -0,0 +1,92 @@ +local embed_create_ctx_mt = {} + +function embed_create_ctx_mt:__index(key) + -- rawget ensures we don't do recursion + local form = rawget(self, "_flow_embed_parent_form") + local prefix = rawget(self, "_flow_embed_prefix") + return form[prefix .. key] +end + +function embed_create_ctx_mt:__newindex(key, value) + local form = rawget(self, "_flow_embed_parent_form") + local prefix = rawget(self, "_flow_embed_prefix") + form[prefix .. key] = value +end + +local function embed_create_ctx(ctx, name, prefix) + if not ctx[name] then + ctx[name] = {} + end + if not ctx[name].form then + ctx[name].form = {} + end + if getmetatable(ctx[name].form) ~= embed_create_ctx_mt then + ctx[name].form._flow_embed_prefix = prefix + ctx[name].form._flow_embed_parent_form = ctx.form + ctx[name].form = setmetatable(ctx[name].form, embed_create_ctx_mt) + end + return ctx[name] +end + +local function embed_wrap_callback_func(func, name, prefix) + return function(player, ctx) + return func(player, embed_create_ctx(ctx, name, prefix)) + end +end + +local function embed_add_prefix(node, name, prefix) + if node.type == "style" and node.selectors then + -- Add prefix to style[] selectors + for i, selector in ipairs(node.selectors) do + node.selectors[i] = prefix .. selector + end + elseif node.type == "scroll_container" and node.scrollbar_name then + node.scrollbar_name = prefix .. node.scrollbar_name + elseif node.type == "tooltip" and node.gui_element_name then + node.gui_element_name = prefix .. node.gui_element_name + end + + -- Add prefix to all names + if node.name then + node.name = prefix .. node.name + end + + -- Wrap callback functions + if node.on_event then + node.on_event = embed_wrap_callback_func(node.on_event, name, prefix) + end + if node.on_quit then + node.on_quit = embed_wrap_callback_func(node.on_quit, name, prefix) + end + + -- Recurse to child nodes + for _, child in ipairs(node) do + embed_add_prefix(child, name, prefix) + end +end + +-- TODO: Unit test this +local change_ctx = ... + +return function(self, fields) + local player = fields.player + local name = fields.name + -- TODO: It might be cool to somehow pass elements down (number-indexes + -- of fields) into the child form, but I'm not sure how that would look + -- on the form definition side. + -- Perhaps passing it in via the context, or an extra arg to _build? + local parent_ctx = flow.get_context() + if name == nil then + -- Don't prefix anything if name is unspecified + return self._build(player, parent_ctx) + end + + local prefix = "\2" .. name .. "\2" + local child_ctx = embed_create_ctx(parent_ctx, name, prefix) + change_ctx(child_ctx) + local root_node = self._build(player, child_ctx) + change_ctx(parent_ctx) + + embed_add_prefix(root_node, name, prefix) + return root_node +end diff --git a/init.lua b/init.lua index b27bdbe..b36a144 100644 --- a/init.lua +++ b/init.lua @@ -20,6 +20,7 @@ local DEBUG_MODE = false flow = {} local S = minetest.get_translator("flow") +local modpath = minetest.get_modpath("flow") local Form = {} @@ -1229,6 +1230,10 @@ function Form:update_where(func) end end +Form.embed = assert(loadfile(modpath .. "/embed.lua"))(function(new_context) + current_ctx = new_context +end) + local form_mt = {__index = Form} function flow.make_gui(build_func) return setmetatable({_build = build_func}, form_mt) @@ -1509,7 +1514,6 @@ function gui_mt.__newindex() error("Cannot modifiy gui table") end -local modpath = minetest.get_modpath("flow") if minetest.is_singleplayer() then local example_form minetest.register_chatcommand("flow-example", { diff --git a/test.lua b/test.lua index 85eeb32..e34f273 100644 --- a/test.lua +++ b/test.lua @@ -20,11 +20,18 @@ end local function dummy() end minetest.register_on_leaveplayer = dummy -minetest.get_modpath = dummy minetest.is_singleplayer = dummy minetest.get_player_information = dummy minetest.show_formspec = dummy +function minetest.get_modpath(modname) + if modname == "flow" then + return "." + elseif modname == "formspec_ast" then + return FORMSPEC_AST_PATH + end +end + function minetest.get_translator(modname) assert(modname == "flow") return function(str) return str end @@ -109,16 +116,25 @@ local function render(build_func, ctx, fs_ver) return form:_render({get_player_name = "test"}, ctx or {}, fs_ver) end -local function test_render(build_func, output) +local function test_render(build_func, output, description) local tree = render(build_func) - local expected_tree = assert(formspec_ast.parse(output)) - - assert.same(normalise_tree(expected_tree), normalise_tree(tree)) + local expected_tree = output + if type(output) == "string" then + expected_tree = assert(formspec_ast.parse(output), "expected output must parse") + end + if expected_tree.type then + expected_tree = assert(render(expected_tree), "if expected output is a flow form, it must render") + end + tree = normalise_tree(tree) + expected_tree = normalise_tree(expected_tree) + assert.same(expected_tree, tree, description) end local function render_to_string(tree) local player = stub_player("test_player") - local form = flow.make_gui(function() return table.copy(tree) end) + local form = flow.make_gui(function() + return table.copy(tree) + end) local ctx = {} local _, event = form:render_to_formspec_string(player, ctx) return ctx, event @@ -903,4 +919,218 @@ describe("Flow", function() ]]) end) end) + + describe("Flow.embed", function() + local embedded_form = flow.make_gui(function(_, x) + return gui.VBox{ + gui.Label{label = "This is the embedded form!"}, + gui.Field{name = "test2"}, + x.a and gui.Label{label = "A is true!" .. x.a} or gui.Nil{} + } + end) + it("raises an error if called outside of a form context", function() + assert.has_error(function() + embedded_form:embed{ + -- It's fully possible that the API user would have access + -- to a player reference + player = stub_player"test_player", + name = "theprefix" + } + end) + end) + it("returns a flow widget", function () + test_render(function(p, _) + return gui.HBox{ + gui.Label{label = "asdft"}, + embedded_form:embed{player = p, name = "theprefix"}, + gui.Label{label = "ffaksksdf"} + } + end, gui.HBox{ + gui.Label{label = "asdft"}, + gui.VBox{ + gui.Label{label = "This is the embedded form!"}, + -- The exact prefix is an implementation detail, you + -- shouldn't rely on this in your own code + gui.Field{name = "\2theprefix\2test2"}, + }, + gui.Label{label = "ffaksksdf"} + }) + end) + it("supports nil prefix", function() + test_render(function(p, _) + return gui.HBox{ + gui.Label{label = "asdft"}, + embedded_form:embed{player = p}, + gui.Label{label = "ffaksksdf"} + } + end, gui.HBox{ + gui.Label{label = "asdft"}, + gui.VBox{ + gui.Label{label = "This is the embedded form!"}, + gui.Field{name = "test2"}, + }, + gui.Label{label = "ffaksksdf"} + }) + end) + it("child context object lives inside the host", function() + test_render(function(p, x) + assert.Nil( + x.theprefix, + "Prefixes are inserted when :embed is called. ".. + "The first time this renders, it hasn't been called yet." + ) + -- Technically, that means both of these will be true the first time + -- This code only ever runs once, so that's every time. + -- Regardless, this is how ordinary API users would be using it. + if not x.theprefix then + x.theprefix = {} + end + if not x.theprefix.a then + x.theprefix.a = " WOW!" + end + return gui.HBox{ + gui.Label{label = "asdft"}, + embedded_form:embed{player = p, name = "theprefix"}, + gui.Label{label = "ffaksksdf"} + } + end, gui.HBox{ + gui.Label{label = "asdft"}, + gui.VBox{ + gui.Label{label = "This is the embedded form!"}, + gui.Field{name = "\2theprefix\2test2"}, + gui.Label{label = "A is true! WOW!"} + }, + gui.Label{label = "ffaksksdf"} + }) + end) + it("flow form context table", function() + test_render(function(p, x) + x.form["\2the_name\2jkl"] = 3 + local child = flow.make_gui(function(_p, xc) + xc.form.thingy = true + xc.form.jkl = 9 + return gui.Label{label = "asdf"} + end):embed{ + player = p, + name = "the_name" + } + assert.True(x.form["\2the_name\2thingy"]) + assert.equal(9, x.form["\2the_name\2jkl"]) + return child + end, gui.Label{label = "asdf"}) + end) + it("host may modify the returned flow form", function() + test_render(function(p, _x) + local e = embedded_form:embed{player = p, name = "asdf"} + e[#e+1] = gui.Box{w = 1, h = 3} + return e + end, gui.VBox{ + gui.Label{label = "This is the embedded form!"}, + gui.Field{name = "\2asdf\2test2"}, + gui.Box{w = 1, h = 3} + }) + end) + it("event handler called correctly", function() + local function func_btn_event() end + local function func_field_event() return true end + local function func_quit() end + + func_btn_event = spy.new(func_btn_event) + func_field_event = spy.new(func_field_event) + func_quit = spy.new(func_quit) + + local wrapped_p, wrapped_x + local event_embedded_form = flow.make_gui(function(p, x) + wrapped_p, wrapped_x = p, x + return gui.VBox{ + on_quit = func_quit, + gui.Label{label = "Callback demo:"}, + gui.Button{label = "Click me!", name = "btn", on_event = func_btn_event}, + gui.Field{name = "field", on_event = func_field_event} + } + end) + + local _tree, state = render(function(player, _ctx) + return event_embedded_form:embed{ + player = player, + name = "thesubform" + } + end) + + local player, ctx = wrapped_p, state.ctx + state.callbacks.quit(player, ctx) + state.callbacks["\2thesubform\2field"](player, ctx) + state.btn_callbacks["\2thesubform\2btn"](player, ctx) + + assert.same(state.ctx.thesubform, wrapped_x) + + assert.spy(func_quit).was.called(1) + assert.spy(func_quit).was.called_with(player, wrapped_x) + assert.spy(func_field_event).was.called(1) + assert.spy(func_field_event).was.called_with(player, wrapped_x) + assert.spy(func_btn_event).was.called(1) + assert.spy(func_btn_event).was.called_with(player, wrapped_x) + + -- Each of these are wrapped with another function to put the actual function in the correct environment + assert.Not.same(func_quit, state.callbacks.quit) + assert.Not.same(func_field_event, state.callbacks["\2thesubform\2field"]) + assert.Not.same(func_btn_event, state.callbacks["\2thesubform\2btn"]) + end) + describe("metadata", function() + it("style data is modified", function() + local style_embedded_form = flow.make_gui(function (p, x) + return gui.VBox{ + gui.Style{selectors = {"test"}, props = {prop = "value"}}, + } + end) + test_render(function(p, _x) + return style_embedded_form:embed{player = p, name = "asdf"} + end, gui.VBox{ + gui.Style{selectors = {"\2asdf\2test"}, props = {prop = "value"}}, + }) + end) + it("scroll_container data is modified", function() + local scroll_embedded_form = flow.make_gui(function(p, x) + return gui.VBox{ + gui.ScrollContainer{scrollbar_name = "name"} + } + end) + test_render(function(p, _x) + return scroll_embedded_form:embed{player = p, name = "asdf"} + end, gui.VBox{ + gui.ScrollContainer{scrollbar_name = "\2asdf\2name"} + }) + end) + it("tooltip data is modified", function() + local tooltip_embedded_form = flow.make_gui(function(p, x) + return gui.VBox{ + gui.Tooltip{gui_element_name = "lololol"} + } + end) + test_render(function(p, _x) + return tooltip_embedded_form:embed{player = p, name = "asdf"} + end, gui.VBox{ + gui.Tooltip{gui_element_name = "\2asdf\2lololol"} + }) + end) + end) + it("supports fresh initial form values", function() + local tooltip_embedded_form = flow.make_gui(function(p, x) + assert.same("initial value!", x.field) + return gui.VBox{ + gui.Field{name = "field"} + } + end) + test_render(function(p, x) + if not x.asdf then + x.asdf = { + field = "initial value!" + } + end + return tooltip_embedded_form:embed{player = p, name = "asdf"} + end, gui.VBox{ + gui.Field{name = "\2asdf\2field"} + }) + end) + end) end)