-- -- Luanti formspec layout engine -- -- Copyright © 2022 by luk3yx -- -- This program is free software: you can redistribute it and/or modify -- it under the terms of the GNU Lesser General Public License as published by -- the Free Software Foundation, either version 2.1 of the License, or -- (at your option) any later version. -- -- This program is distributed in the hope that it will be useful, -- but WITHOUT ANY WARRANTY; without even the implied warranty of -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -- GNU Lesser General Public License for more details. -- -- You should have received a copy of the GNU Lesser General Public License -- along with this program. If not, see . -- local DEBUG_MODE = false flow = {} local S = core.get_translator("flow") local modpath = core.get_modpath("flow") local Form = {} local ceil, floor, min, max = math.ceil, math.floor, math.min, math.max -- Estimates the width of a valid UTF-8 string, ignoring any escape sequences. -- This function hopefully works with most (but not all) scripts, maybe it -- could still be improved. local byte, strlen = string.byte, string.len local LPAREN = byte("(") local function naive_str_width(str) local w = 0 local prev_w = 0 local line_count = 1 local i = 1 -- string.len() is used so that numbers are coerced to strings without any -- extra checking local str_length = strlen(str) while i <= str_length do local char = byte(str, i) if char == 0x1b then -- Ignore escape sequences i = i + 1 if byte(str, i) == LPAREN then i = str:find(")", i + 1, true) or str_length end elseif char == 0xe1 then if (byte(str, i + 1) or 0) < 0x84 then -- U+1000 - U+10FF w = w + 1 else -- U+1100 - U+2000 w = w + 2 end i = i + 2 elseif char > 0xe1 and char < 0xf5 then -- U+2000 - U+10FFFF w = w + 2 i = i + 2 elseif char == 0x0a then -- Newlines: Reset the width and increase the line count prev_w = max(prev_w, w) w = 0 line_count = line_count + 1 elseif char < 0x80 or char > 0xbf then -- Everything except UTF-8 continuation sequences w = w + 1 end i = i + 1 end return max(w, prev_w), line_count end local LABEL_HEIGHT = 0.4 local LABEL_OFFSET = LABEL_HEIGHT / 2 local CHAR_WIDTH = 0.21 -- The "current_lang" variable isn't ideal but means that the language will be -- known inside ScrollableVBox etc local current_lang -- get_translated_string doesn't exist in MT 5.2.0 and older local get_translated_string = core.get_translated_string or function(_, s) return s end local function get_lines_size(lines) local w = 0 for _, line in ipairs(lines) do -- Translate the string if necessary if current_lang and current_lang ~= "" and current_lang ~= "en" then line = get_translated_string(current_lang, line) end w = max(w, naive_str_width(line) * CHAR_WIDTH) end return w, LABEL_HEIGHT * #lines end local function get_label_size(label) label = label or "" if current_lang and current_lang ~= "" and current_lang ~= "en" then label = get_translated_string(current_lang, label) end local longest_line_width, line_count = naive_str_width(label) return longest_line_width * CHAR_WIDTH, line_count * LABEL_HEIGHT end local size_getters = {} local function get_and_fill_in_sizes(node) if node.type == "list" then return node.w * 1.25 - 0.25, node.h * 1.25 - 0.25 end if node.w and node.h then return node.w, node.h end local f = size_getters[node.type] if not f then return 0, 0 end local w, h = f(node) node.w = node.w or max(w, node.min_w or 0) node.h = node.h or max(h, node.min_h or 0) return node.w, node.h end function size_getters.container(node) local w, h = 0, 0 for _, n in ipairs(node) do local w2, h2 = get_and_fill_in_sizes(n) w = max(w, (n.x or 0) + w2) h = max(h, (n.y or 0) + h2) end return w, h end size_getters.scroll_container = size_getters.container function size_getters.label(node) local w, h = get_label_size(node.label) return w, LABEL_HEIGHT + (h - LABEL_HEIGHT) * 1.25 end local MIN_BUTTON_HEIGHT = 0.8 function size_getters.button(node) local x, y = get_label_size(node.label) return max(x, MIN_BUTTON_HEIGHT * 2), max(y, MIN_BUTTON_HEIGHT) end size_getters.button_exit = size_getters.button size_getters.image_button = size_getters.button size_getters.image_button_exit = size_getters.button size_getters.item_image_button = size_getters.button size_getters.button_url = size_getters.button function size_getters.field(node) local label_w, label_h = get_label_size(node.label) -- This is done in apply_padding as well but the label size has already -- been calculated here if not node._padding_top and node.label and #node.label > 0 then node._padding_top = label_h end local w, h = get_label_size(node.default) return max(w, label_w, 3), max(h, MIN_BUTTON_HEIGHT) end size_getters.pwdfield = size_getters.field size_getters.textarea = size_getters.field function size_getters.vertlabel(node) return CHAR_WIDTH, #node.label * LABEL_HEIGHT end function size_getters.textlist(node) local w, h = get_lines_size(node.listelems) return w, h * 1.1 end function size_getters.dropdown(node) return max(get_lines_size(node.items) + 0.3, 2), MIN_BUTTON_HEIGHT end function size_getters.checkbox(node) local w, h = get_label_size(node.label) return w + 0.4, h end local field_elems = {field = true, pwdfield = true, textarea = true} local function apply_padding(node, x, y) local w, h = get_and_fill_in_sizes(node) -- Labels are positioned from the centre of the first line and checkboxes -- are positioned from the centre. if node.type == "label" then y = y + LABEL_OFFSET elseif node.type == "checkbox" then y = y + h / 2 elseif field_elems[node.type] and not node._padding_top and node.label and #node.label > 0 then -- Add _padding_top to fields with labels that have a fixed size set local _, label_h = get_label_size(node.label) node._padding_top = label_h elseif node.type == "tabheader" and w > 0 and h > 0 then -- Handle tabheader if the width and height are set -- I'm not sure what to do with tabheaders that don't have a width or -- height set. y = y + h end if node._padding_top then y = y + node._padding_top h = h + node._padding_top end local padding = node.padding if padding then x = x + padding y = y + padding w = w + padding * 2 h = h + padding * 2 end node.x, node.y = x, y return w, h end local invisible_elems = { style = true, listring = true, scrollbaroptions = true, tableoptions = true, tablecolumns = true, tooltip = true, style_type = true, set_focus = true, listcolors = true } local DEFAULT_SPACING = 0.2 function size_getters.vbox(vbox) local spacing = vbox.spacing or DEFAULT_SPACING local width = 0 local y = 0 for _, node in ipairs(vbox) do if not invisible_elems[node.type] then if y > 0 then y = y + spacing end local w, h = apply_padding(node, 0, y) width = max(width, w) y = y + h end end return width, y end function size_getters.hbox(hbox) local spacing = hbox.spacing or DEFAULT_SPACING local x = 0 local height = 0 for _, node in ipairs(hbox) do if not invisible_elems[node.type] then if x > 0 then x = x + spacing end local w, h = apply_padding(node, x, 0) height = max(height, h) x = x + w end end return x, height end function size_getters.stack(stack) local width, height = 0, 0 for _, node in ipairs(stack) do if not invisible_elems[node.type] then local w, h = apply_padding(node, 0, 0) width = max(width, w) height = max(height, h) end end return width, height end function size_getters.padding(node) core.log("warning", "[flow] The gui.Padding element is deprecated") assert(#node == 1, "Padding can only have one element inside.") local n = node[1] local x, y = apply_padding(n, 0, 0) if node.expand == nil then node.expand = n.expand end return x, y end local align_types = {} function align_types.fill(node, x, w, extra_space) -- Special cases if node.type == "list" or node.type == "checkbox" or node._label_hack then return align_types.centre(node, x, w, extra_space) elseif node.type == "label" then if x == "y" then node.y = node.y + extra_space / 2 return end -- Hack node.type = "container" -- Reset bgimg, some games apply styling to all image_buttons inside -- the formspec prepend node[1] = { type = "style", -- MT 5.1.0 only supports one style selector selectors = {"_#"}, -- bgimg_pressed is included for 5.1.0 support -- bgimg_hovered is unnecessary as it was added in 5.2.0 (which -- also adds support for :hovered and :pressed) props = {bgimg = "", bgimg_pressed = ""}, } -- Use the newer pressed selector as well in case the deprecated one is -- removed node[2] = { type = "style", selectors = {"_#:hovered", "_#:pressed"}, props = {bgimg = ""}, } node[3] = { type = "image_button", texture_name = "blank.png", drawborder = false, x = 0, y = 0, w = node.w + extra_space, h = node.h, name = "_#", label = node.label, style = node.style, } -- Overlay button to prevent clicks from doing anything node[4] = { type = "image_button", texture_name = "blank.png", drawborder = false, x = 0, y = 0, w = node.w + extra_space, h = node.h, name = "_#", label = "", } node.y = node.y - LABEL_OFFSET node.label = nil node.style = nil node._label_hack = true assert(#node == 4) end if node[w] then node[w] = node[w] + extra_space else core.log("warning", "[flow] Unknown element: \"" .. tostring(node.type) .. "\". Please make sure that flow is " .. "up-to-date and the element has a size set (if required).") node[w] = extra_space end end function align_types.start() -- No alterations required end -- "end" is a Lua keyword align_types["end"] = function(node, x, _, extra_space) node[x] = node[x] + extra_space end -- Aliases for convenience align_types.top, align_types.bottom = align_types.start, align_types["end"] align_types.left, align_types.right = align_types.start, align_types["end"] function align_types.centre(node, x, w, extra_space) if node.type == "label" then return align_types.fill(node, x, w, extra_space) elseif node.type == "checkbox" and x == "y" then node.y = (node.h + extra_space) / 2 return end node[x] = node[x] + extra_space / 2 end align_types.center = align_types.centre -- Try to guess at what the best expansion setting is local auto_align_centre = { image = true, animated_image = true, model = true, item_image_button = true } function align_types.auto(node, x, w, extra_space, cross) if auto_align_centre[node.type] then return align_types.centre(node, x, w, extra_space) end if x == "y" or (node.type ~= "label" and node.type ~= "checkbox") or (node.expand and not cross) then return align_types.fill(node, x, w, extra_space) end end local expand_child_boxes local function expand(box) local x, w, align_h, y, h, align_v local box_type = box.type if box_type == "hbox" then x, w, align_h, y, h, align_v = "x", "w", "align_h", "y", "h", "align_v" elseif box_type == "vbox" then x, w, align_h, y, h, align_v = "y", "h", "align_v", "x", "w", "align_h" elseif box_type == "stack" or (box_type == "padding" and box[1].expand) then box.type = "container" box._enable_bgimg_hack = true for _, node in ipairs(box) do if not invisible_elems[node.type] then local width, height = node.w or 0, node.h or 0 if node.type == "list" then width = width * 1.25 - 0.25 height = height * 1.25 - 0.25 end local padding_x2 = (node.padding or 0) * 2 align_types[node.align_h or "auto"](node, "x", "w", box.w - width - padding_x2) align_types[node.align_v or "auto"](node, "y", "h", box.h - height - padding_x2 - (node._padding_top or 0)) end end return expand_child_boxes(box) elseif box_type == "container" or box_type == "scroll_container" then for _, node in ipairs(box) do if node.x == 0 and node.expand and box.w then node.w = box.w end expand(node) end return elseif box_type == "padding" then box.type = "container" return expand_child_boxes(box) else return end box.type = "container" -- Calculate the amount of free space and put expand nodes into a table local box_h = box[h] local free_space = box[w] local expandable = {} local expand_count = 0 local first = true for i, node in ipairs(box) do local width, height = node[w] or 0, node[h] or 0 if not invisible_elems[node.type] then if first then first = false else free_space = free_space - (box.spacing or DEFAULT_SPACING) end if node.type == "list" then width = width * 1.25 - 0.25 height = height * 1.25 - 0.25 end free_space = free_space - width - (node.padding or 0) * 2 - (y == "x" and node._padding_top or 0) if node.expand then expandable[node] = i expand_count = expand_count + 1 elseif node.type == "label" and align_h == "align_h" then -- Use the image_button hack even if the label isn't expanded align_types[node.align_h or "auto"](node, "x", "w", 0) end -- Nodes are expanded in the other direction no matter what their -- expand setting is if box_h > height or (node.type == "label" and align_v == "align_h") then align_types[node[align_v] or "auto"](node, y, h, box_h - height - (node.padding or 0) * 2 - (y == "y" and node._padding_top or 0), true) end end end -- If there's any free space then expand the nodes to fit if free_space > 0 then local extra_space = free_space / expand_count for node, node_idx in pairs(expandable) do align_types[node[align_h] or "auto"](node, x, w, extra_space) -- Shift other elements along for j = node_idx + 1, #box do if box[j][x] then box[j][x] = box[j][x] + extra_space end end end elseif align_h == "align_h" then -- Use the image_button hack on labels regardless of the amount of free -- space if this is in a horizontal box. for node in pairs(expandable) do if node.type == "label" then align_types[node.align_h or "auto"](node, "x", "w", 0) end end end expand_child_boxes(box) end function expand_child_boxes(box) -- Recursively expand and remove any invisible nodes for i = #box, 1, -1 do local node = box[i] -- node.visible ~= nil and not node.visible if node.visible == false then -- There's no need to try and expand anything inside invisible -- nodes since it won't affect the overall size. table.remove(box, i) else expand(node) end end end -- Renders the GUI into hopefully valid AST -- This won't fill in names local function render_ast(node, embedded) local t1 = DEBUG_MODE and core.get_us_time() node.padding = node.padding or 0.3 local w, h = apply_padding(node, 0, 0) local t2 = DEBUG_MODE and core.get_us_time() expand(node) local t3 = DEBUG_MODE and core.get_us_time() local res = { formspec_version = 7, {type = "size", w = w, h = h}, } -- TODO: Consider a nicer place to put these parameters if node.no_prepend and not embedded then res[#res + 1] = {type = "no_prepend"} end if node.fbgcolor or node.bgcolor or node.bg_fullscreen ~= nil then -- Hack to prevent breaking mods that rely on the old (broken) -- behaviour of fbgcolor if node.fbgcolor == "#08080880" and node.bgcolor == nil and node.bg_fullscreen == nil then node.bg_fullscreen = true node.fbgcolor = nil end res[#res + 1] = { type = "bgcolor", bgcolor = node.bgcolor, fbgcolor = node.fbgcolor, fullscreen = node.bg_fullscreen } node.bgcolor = nil node.fbgcolor = nil node.bg_fullscreen = nil end -- Add the root element's background image as a fullscreen one if node.bgimg and not embedded then res[#res + 1] = { type = node.bgimg_middle and "background9" or "background", texture_name = node.bgimg, middle_x = node.bgimg_middle, x = 0, y = 0, w = 0, h = 0, auto_clip = true, } node.bgimg = nil end res[#res + 1] = node if DEBUG_MODE then local t4 = core.get_us_time() print('apply_padding', t2 - t1) print('expand', t3 - t2) print('field_close_on_enter', t4 - t3) end return res end local function chain_cb(f1, f2) return function(...) f1(...) f2(...) end end local function range_check_transformer(items_length) return function(value) local num = tonumber(value) if num and num == num then num = floor(num) if num >= 1 and num <= items_length then return num end end end end local function simple_transformer(func) return function() return func end end -- Functions that transform field values into the easiest to use type local C1_CHARS = "\194[\128-\159]" local field_value_transformers = { field = simple_transformer(function(value) -- Remove control characters and newlines return value:gsub("[%z\1-\8\10-\31\127]", ""):gsub(C1_CHARS, "") end), checkbox = simple_transformer(core.is_yes), -- Scrollbars do have min/max values but scrollbars are only really used by -- ScrollableVBox which doesn't need the extra checks scrollbar = simple_transformer(function(value) return core.explode_scrollbar_event(value).value end), } -- Field value transformers that depend on some property of the element function field_value_transformers.tabheader(node) return range_check_transformer(node.captions and #node.captions or 0) end function field_value_transformers.dropdown(node, _, formspec_version) local items = node.items or {} if node.index_event and not node._index_event_hack then return range_check_transformer(#items) end -- MT will start sanitising formspec fields on its own at some point -- (https://github.com/minetest/minetest/pull/14878), however it may strip -- escape sequences from dropdowns as well. Since we know what the actual -- value of the dropdown is anyway, we can just enable index_event for new -- clients and keep the same behaviour if (formspec_version and formspec_version >= 4) or (core.global_exists("fs51") and fs51.monkey_patching_enabled) then node.index_event = true -- Detect reuse of the same Dropdown element (this is unsupported and -- will break in other ways) node._index_event_hack = true return function(value) return items[tonumber(value)] end elseif node._index_event_hack then node.index_event = nil end -- Make sure that the value sent by the client is in the list of items return function(value) if table.indexof(items, value) > 0 then return value end end end function field_value_transformers.table(node, tablecolumn_count) -- Figure out how many rows the table has local cells = node.cells and #node.cells or 0 local rows = ceil(cells / tablecolumn_count) return function(value) local row = floor(core.explode_table_event(value).row) -- Tables and textlists can have values of 0 (nothing selected) but I -- don't think the client can un-select a row so it should be safe to -- ignore any 0 sent by the client to guarantee that the row will be -- valid if the default value is valid if row >= 1 and row <= rows then return row end end end function field_value_transformers.textlist(node) local rows = node.listelems and #node.listelems or 0 return function(value) local index = floor(core.explode_textlist_event(value).index) if index >= 1 and index <= rows then return index end end end local function default_field_value_transformer(value) -- Remove control characters (but preserve newlines) -- Pattern by https://github.com/appgurueu return value:gsub("[%z\1-\8\11-\31\127]", ""):gsub(C1_CHARS, "") end local default_value_fields = { field = "default", pwdfield = "default", textarea = "default", checkbox = "selected", dropdown = "selected_idx", table = "selected_idx", textlist = "selected_idx", scrollbar = "value", tabheader = "current_tab", } local sensible_defaults = { default = "", selected = false, selected_idx = 1, value = 0, } local button_types = { button = true, image_button = true, item_image_button = true, button_exit = true, image_button_exit = true } -- Removes on_event from a formspec_ast tree and returns a callbacks table local function parse_callbacks(tree, ctx_form, auto_name_id, replace_backgrounds, formspec_version) local callbacks local btn_callbacks = {} local saved_fields = {} local tablecolumn_count = 1 for node in formspec_ast.walk(tree) do if node.type == "container" then if node.bgcolor then local padding = node.padding or 0 table.insert(node, 1, { type = "box", color = node.bgcolor, x = -padding, y = -padding, w = node.w + padding * 2, h = node.h + padding * 2, }) end if node.bgimg then local padding = node.padding or 0 table.insert(node, 1, { type = node.bgimg_middle and "background9" or "background", texture_name = node.bgimg, middle_x = node.bgimg_middle, x = -padding, y = -padding, w = node.w + padding * 2, h = node.h + padding * 2, }) end -- The on_quit callback is undocumented and not recommended, it -- only gets called when the client tells the server that it's -- closing the form and not when another form is shown. if node.on_quit then callbacks = callbacks or {} if callbacks.quit then -- HACK callbacks.quit = chain_cb(callbacks.quit, node.on_quit) else callbacks.quit = node.on_quit end end replace_backgrounds = replace_backgrounds or node._enable_bgimg_hack elseif node.type == "tablecolumns" and node.tablecolumns then -- Store the amount of columns for input validation tablecolumn_count = max(#node.tablecolumns, 1) elseif replace_backgrounds then if (node.type == "background" or node.type == "background9") and not node.auto_clip then node.type = "image" end elseif node.type == "scroll_container" then -- Work around a Minetest bug with scroll containers not scrolling -- backgrounds. replace_backgrounds = true end local node_name = node.name if node_name and node_name ~= "" then local value_field = default_value_fields[node.type] if value_field then -- Update ctx.form if there is no current value, otherwise -- change the node's value to the saved one. local value = ctx_form[node_name] if node.type == "dropdown" and (not node.index_event or node._index_event_hack) then -- Special case for dropdowns without index_event local items = node.items or {} if value == nil then ctx_form[node_name] = items[node.selected_idx or 1] else local idx = table.indexof(items, value) if idx > 0 then node.selected_idx = idx end end node.selected_idx = node.selected_idx or 1 elseif value == nil then -- If ctx.form[node_name] doesn't exist, then check whether -- a default value is specified. local default_value = node[value_field] local sensible_default = sensible_defaults[value_field] if default_value == nil then -- If the element doesn't have a default set, set it to -- the sensible default value and update ctx.form in -- case the client doesn't send the field value back. node[value_field] = sensible_default ctx_form[node_name] = sensible_default else -- Update ctx.form to the default value ctx_form[node_name] = default_value end else -- Set the node's value to the one saved in ctx.form node[value_field] = value end -- Add the corresponding value transformer transformer to -- saved_fields local get_transformer = field_value_transformers[node.type] saved_fields[node_name] = get_transformer and get_transformer(node, tablecolumn_count, formspec_version) or default_field_value_transformer end end -- Add the on_event callback (if any) to the callbacks table if node.on_event then local is_btn = button_types[node.type] if not node_name then -- Flow internal field names start with "_#" to avoid -- conflicts with user-provided fields. node_name = ("_#%x"):format(auto_name_id) node.name = node_name auto_name_id = auto_name_id + 1 elseif btn_callbacks[node_name] or (is_btn and saved_fields[node_name]) or (callbacks and callbacks[node_name]) then core.log("warning", ("[flow] Multiple callbacks have " .. "been registered for elements with the same name (%q), " .. "this will not work properly."):format(node_name)) -- Preserve previous behaviour btn_callbacks[node_name] = nil if callbacks then callbacks[node_name] = nil end is_btn = is_btn and not saved_fields[node_name] end -- Put buttons into a separate callback table so that malicious -- clients can't send multiple button presses in one submission if is_btn then btn_callbacks[node_name] = node.on_event else callbacks = callbacks or {} callbacks[node_name] = node.on_event end node.on_event = nil end -- Call _after_positioned (used internally for ScrollableVBox) if node._after_positioned then node:_after_positioned() node._after_positioned = nil end end return callbacks, btn_callbacks, saved_fields, auto_name_id end local gui_mt = { __index = function(gui, k) local elem_type = k if elem_type ~= "ScrollbarOptions" and elem_type ~= "TableOptions" and elem_type ~= "TableColumns" then elem_type = elem_type:gsub("([a-z])([A-Z])", function(a, b) return a .. "_" .. b end) end elem_type = elem_type:lower() local function f(t) t.type = elem_type return t end rawset(gui, k, f) return f end, } local gui = setmetatable({ embed = function(fs, w, h) core.log("warning", "[flow] gui.embed() is deprecated") if type(fs) ~= "table" then fs = formspec_ast.parse(fs) end fs.type = "container" fs.w = w fs.h = h return fs end, formspec_version = 0, }, gui_mt) flow.widgets = gui local current_ctx function flow.get_context() if not current_ctx then error("get_context() was called outside of a GUI function!", 2) end return current_ctx end -- Returns the new index of the affected element local function insert_style_elem(tree, idx, node, props, sels) if not next(props) then -- No properties, don't try and add an empty style element return idx end local base_selector = node.name or node.type local selectors = {} if sels then for i, sel in ipairs(sels) do local suffix = sel:match("^%s*$(.-)%s*$") if suffix then selectors[i] = base_selector .. ":" .. suffix else core.log("warning", "[flow] Invalid style selector: " .. tostring(sel)) end end else selectors[1] = base_selector end table.insert(tree, idx, { type = node.name and "style" or "style_type", selectors = selectors, props = props, }) if not node.name then -- Undo style_type modifications local reset_props = {} for k in pairs(props) do -- The style table might have substyles which haven't been removed -- yet reset_props[k] = "" end table.insert(tree, idx + 2, { type = "style_type", selectors = selectors, props = reset_props, }) end return idx + 1 end local function extract_props(t) local res = {} for k, v in pairs(t) do if k ~= "sel" and type(k) == "string" then res[k] = v end end return res end -- I don't like the idea of making yet another pass over the element tree but I -- can't think of a clean way of integrating shorthand elements into one of the -- other loops. local function insert_shorthand_elements(tree) for i = #tree, 1, -1 do local node = tree[i] -- Insert styles if node.style then local props = node.style if #node.style > 0 then -- Make a copy of node.style without the numeric keys. This -- avoids modifying node.style in case it's used for multiple -- elements. props = extract_props(props) end local next_idx = insert_style_elem(tree, i, node, props) for _, substyle in ipairs(node.style) do next_idx = insert_style_elem(tree, next_idx, node, extract_props(substyle), substyle.sel:split(",")) end end -- Insert tooltips if node.tooltip then if node.name then table.insert(tree, i, { type = "tooltip", gui_element_name = node.name, tooltip_text = node.tooltip, }) else local w, h = get_and_fill_in_sizes(node) table.insert(tree, i, { type = "tooltip", x = node.x, y = node.y, w = w, h = h, tooltip_text = node.tooltip, }) end end if node.type == "container" or node.type == "scroll_container" then insert_shorthand_elements(node) elseif node.type == "field" then table.insert(tree, i, { type = 'field_close_on_enter', name = node.name, close_on_enter = false, }) if node.enter_after_edit then table.insert(tree, i, { type = 'field_enter_after_edit', name = node.name, enter_after_edit = true, }) end end end end -- Renders a GUI into a formspec_ast tree and a table with callbacks. function Form:_render(player, ctx, formspec_version, id1, embedded, lang_code) local used_ctx_vars = {} current_lang = lang_code -- Wrap ctx.form local orig_form = ctx.form or {} local wrapped_form = setmetatable({}, { __index = function(_, key) used_ctx_vars[key] = true return orig_form[key] end, __newindex = orig_form, }) ctx.form = wrapped_form gui.formspec_version = formspec_version or 0 current_ctx = ctx local box = self._build(player, ctx) current_ctx = nil gui.formspec_version = 0 -- Restore the original ctx.form assert(ctx.form == wrapped_form, "Changing the value of ctx.form is not supported!") ctx.form = orig_form -- The numbering of automatically named elements is continued from previous -- iterations of the form to work around race conditions if not id1 or id1 > 1e6 then id1 = 0 end local tree = render_ast(box, embedded) local callbacks, btn_callbacks, saved_fields, id2 = parse_callbacks( tree, orig_form, id1, embedded, formspec_version ) -- This should be after parse_callbacks so it can take advantage of -- automatic field naming insert_shorthand_elements(tree) local redraw_if_changed = {} for var in pairs(used_ctx_vars) do -- Only add it if there is no callback and the name exists in the -- formspec. if saved_fields[var] and (not callbacks or not callbacks[var]) then redraw_if_changed[var] = true end end current_lang = nil return tree, { self = self, callbacks = callbacks, btn_callbacks = btn_callbacks, saved_fields = saved_fields, redraw_if_changed = redraw_if_changed, ctx = ctx, auto_name_id = id2, } end local function prepare_form(self, player, formname, ctx, auto_name_id) local name = player:get_player_name() -- local t = DEBUG_MODE and core.get_us_time() local info = core.get_player_information(name) local tree, form_info = self:_render(player, ctx, info and info.formspec_version, auto_name_id, false, info and info.lang_code) -- local t2 = DEBUG_MODE and core.get_us_time() local fs = assert(formspec_ast.unparse(tree)) -- local t3 = DEBUG_MODE and core.get_us_time() form_info.formname = formname -- if DEBUG_MODE then -- print(t3 - t, t2 - t, t3 - t2) -- end return fs, form_info end local open_formspecs = {} local function show_form(self, player, formname, ctx, auto_name_id) local name = player:get_player_name() local fs, form_info = prepare_form(self, player, formname, ctx, auto_name_id) open_formspecs[name] = form_info core.show_formspec(name, formname, fs) end local next_formname = 0 function Form:show(player, ctx) if type(player) == "string" then core.log("warning", "[flow] Calling form:show() with a player name is deprecated") player = core.get_player_by_name(player) if not player then return end end -- Use a unique form name every time a new form is shown show_form(self, player, ("flow:%x"):format(next_formname), ctx or {}) -- Form name collisions are theoretically possible but probably won't -- happen in practice (and if they do the impact will be minimal) next_formname = (next_formname + 1) % 2^53 end function Form:show_hud(player, ctx) local info = core.get_player_information(player:get_player_name()) local tree = self:_render(player, ctx or {}, nil, nil, nil, info and info.lang_code) hud_fs.show_hud(player, self, tree) end local open_inv_formspecs = {} function Form:set_as_inventory_for(player, ctx) local name = player:get_player_name() local old_form_info = open_inv_formspecs[name] if not ctx and old_form_info and old_form_info.self == self then ctx = old_form_info.ctx end -- Formname of "" is inventory local fs, form_info = prepare_form(self, player, "", ctx or {}, old_form_info and old_form_info.auto_name_id) open_inv_formspecs[name] = form_info player:set_inventory_formspec(fs) end -- Declared here to be accessible by render_to_formspec_string local fs_process_events -- Prevent collisions in forms, but also ensure they don't happen across -- mutliple embedded forms within a single parent. -- Unique per-user to prevent players from making the counter wrap around for -- other players. local render_to_formspec_auto_name_ids = {} -- If `standalone` is set, this will return a standalone formspec, otherwise it -- will return a formspec that can be embedded and a table with its size and -- target formspec version function Form:render_to_formspec_string(player, ctx, standalone) local name = player:get_player_name() local info = core.get_player_information(name) local tree, form_info = self:_render(player, ctx or {}, info and info.formspec_version, render_to_formspec_auto_name_ids[name], not standalone, info and info.lang_code) local public_form_info if not standalone then local size = table.remove(tree, 1) public_form_info = {w = size.w, h = size.h, formspec_version = tree.formspec_version} tree.formspec_version = nil end local fs = assert(formspec_ast.unparse(tree)) render_to_formspec_auto_name_ids[name] = form_info.auto_name_id local function event(fields) -- Just in case the player goes offline, we should not keep the player -- reference. Nothing prevents the user from calling this function when -- the player is offline, unlike the _real_ formspec submission. local player = core.get_player_by_name(name) if not player then core.log("warning", "[flow] Player " .. name .. " was offline when render_to_formspec_string event was" .. " triggered. Events were not passed through.") return nil end return fs_process_events(player, form_info, fields) end return fs, event, public_form_info end function Form:close(player) local name = player:get_player_name() local form_info = open_formspecs[name] if form_info and form_info.self == self then open_formspecs[name] = nil core.close_formspec(name, form_info.formname) end end function Form:close_hud(player) hud_fs.close_hud(player, self) end function Form:unset_as_inventory_for(player) local name = player:get_player_name() local form_info = open_inv_formspecs[name] if form_info and form_info.self == self then open_inv_formspecs[name] = nil player:set_inventory_formspec("") end end -- This function may eventually call core.update_formspec if/when it gets -- added (https://github.com/minetest/minetest/issues/13142) local function update_form(self, player, form_info) show_form(self, player, form_info.formname, form_info.ctx, form_info.auto_name_id) end function Form:update(player) local form_info = open_formspecs[player:get_player_name()] if form_info and form_info.self == self then update_form(self, player, form_info) end end function Form:update_where(func) for name, form_info in pairs(open_formspecs) do if form_info.self == self then local player = core.get_player_by_name(name) if player and func(player, form_info.ctx) then update_form(self, player, form_info) end end 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) end -- Declared locally above to be accessible to render_to_formspec_string function fs_process_events(player, form_info, fields) local callbacks = form_info.callbacks local btn_callbacks = form_info.btn_callbacks local ctx = form_info.ctx local redraw_if_changed = form_info.redraw_if_changed local ctx_form = ctx.form -- Update the context before calling any callbacks local redraw_fs = false for field, transformer in pairs(form_info.saved_fields) do local raw_value = fields[field] if raw_value then if #raw_value > 60000 then -- There's probably no legitimate reason for a client send a -- large amount of data and very long strings have the -- potential to break things. Please open an issue if you -- (somehow) need to use longer text in fields. local name = player:get_player_name() core.log("warning", "[flow] Player " .. name .. " tried" .. " submitting a large field value (>60 kB), ignoring.") else local new_value = transformer(raw_value) if new_value ~= nil then if ctx_form[field] ~= new_value then if redraw_if_changed[field] then redraw_fs = true elseif form_info.formname == "" then -- Update the inventory when the player closes it form_info.ctx_form_modified = true end end ctx_form[field] = new_value end end end end -- Run on_event callbacks -- The callbacks table may be nil as adding callbacks to non-buttons is -- likely uncommon (so allocating an empty table would be useless) if callbacks then for field in pairs(fields) do if callbacks[field] and callbacks[field](player, ctx) then redraw_fs = true end end end -- Run button callbacks after all other callbacks as that seems to be the -- most intuitive thing to do -- Note: Try not to rely on the order of on_event callbacks, I may change -- it in the future. for field in pairs(fields) do if btn_callbacks[field] then redraw_fs = btn_callbacks[field](player, ctx) or redraw_fs -- Only run a single button callback break end end return redraw_fs end core.register_on_player_receive_fields(function(player, formname, fields) local name = player:get_player_name() local form_infos = formname == "" and open_inv_formspecs or open_formspecs local form_info = form_infos[name] if not form_info or formname ~= form_info.formname then return end local redraw_fs = fs_process_events(player, form_info, fields) if form_infos[name] ~= form_info then return true end if formname == "" then -- Special case for inventory forms if redraw_fs or (fields.quit and form_info.ctx_form_modified) then form_info.self:set_as_inventory_for(player) end elseif fields.quit then open_formspecs[name] = nil elseif redraw_fs then update_form(form_info.self, player, form_info) end return true end) core.register_on_leaveplayer(function(player) local name = player:get_player_name() open_formspecs[name] = nil open_inv_formspecs[name] = nil render_to_formspec_auto_name_ids[name] = nil end) -- Extra GUI elements -- Please don't modify the gui table in your own code function gui.PaginatedVBox(def) local w, h = def.w, def.h def.w, def.h = nil, nil local paginator_name = "_paginator-" .. assert(def.name) def.type = "vbox" local inner_w, inner_h = get_and_fill_in_sizes(def) h = h or min(inner_h, 5) local ctx = flow.get_context() -- Build a list of pages local page = {} local pages = {page} local max_y = h for _, node in ipairs(def) do if node.y and node.y + (node.h or 0) > max_y then -- Something overflowed, go to a new page page = {} pages[#pages + 1] = page max_y = node.y + h end -- Add to the current page node.x, node.y = nil, nil page[#page + 1] = node end -- Get the current page local current_page = ctx.form[paginator_name] or 1 if current_page > #pages then current_page = #pages ctx.form[paginator_name] = current_page end page = pages[current_page] or {} page.h = h return gui.VBox { min_w = w or inner_w, gui.VBox(page), gui.HBox { gui.Button { label = "<", on_event = function(_, ctx) ctx.form[paginator_name] = max(current_page - 1, 1) return true end, }, gui.Label { label = S("Page @1 of @2", current_page, #pages), align_h = "centre", expand = true, }, gui.Button { label = ">", on_event = function(_, ctx) ctx.form[paginator_name] = current_page + 1 return true end, }, } } end function gui.ScrollableVBox(def) -- On older clients fall back to a paginated vbox if gui.formspec_version < 4 then return gui.PaginatedVBox(def) end local w, h = def.w, def.h local scrollbar_name = "_scrollbar-" .. assert( def.name, "Please provide a name for all ScrollableVBox elements!" ) local align_h, align_v, expand_box = def.align_h, def.align_v, def.expand def.type = "vbox" def.x, def.y = 0, 0 def.w, def.h = nil, nil local inner_w, inner_h = get_and_fill_in_sizes(def) def.w = w or inner_w def.expand = true h = h or min(inner_h, 5) local scrollbar = { w = 0.5, h = 0.5, orientation = "vertical", name = scrollbar_name, } -- Allow properties of the scrollbar (such as the width) to be overridden if def.custom_scrollbar then for k, v in pairs(def.custom_scrollbar) do scrollbar[k] = v end end local opts = {} return gui.HBox { align_h = align_h, align_v = align_v, expand = expand_box, gui.ScrollContainer{ expand = true, w = w or inner_w, h = h, scrollbar_name = scrollbar_name, orientation = "vertical", def, -- Calculate the scrollbar maximum after the scroll container is -- expanded _after_positioned = function(self) opts.max = max(inner_h - self.h + 0.05, 0) * 10 opts.thumbsize = (self.h / inner_h) * (inner_h - self.h) * 10 end, }, gui.ScrollbarOptions{opts = opts}, gui.Scrollbar(scrollbar) } end function gui.Flow(def) local vbox = { type = "vbox", bgcolor = def.bgcolor, bgimg = def.bgimg, align_h = "centre", align_v = "centre", } local width = assert(def.w) local spacing = def.spacing or DEFAULT_SPACING local line = {spacing = spacing} for _, node in ipairs(def) do local w = get_and_fill_in_sizes(node) if w > width then width = def.w vbox[#vbox + 1] = gui.HBox(line) line = {spacing = spacing} end line[#line + 1] = node width = width - w - spacing end vbox[#vbox + 1] = gui.HBox(line) return vbox end function gui.Spacer(def) def.type = "container" assert(#def == 0) -- Spacers default to expanding if def.expand == nil then def.expand = true end -- Prevent an empty container from being added to the resulting form def.visible = false return def end -- For use in inline and or type inline ifs function gui.Nil(def) -- Tooltip elements are ignored when layouting and setting visible = false -- ensures that the element won't get added to the resulting formspec def.visible = false return gui.Tooltip(def) end -- Prevent any further modifications to the gui table function gui_mt.__newindex() error("Cannot modifiy gui table") end if core.is_singleplayer() then local example_form core.register_chatcommand("flow-example", { privs = {server = true}, help = S"Shows an example form", func = function(name) -- Only load example.lua when it's needed if not example_form then example_form = dofile(modpath .. "/example.lua") end example_form:show(core.get_player_by_name(name)) end, }) end if DEBUG_MODE then local f, err = loadfile(modpath .. "/test-fs.lua") if f then return f() end core.log("error", "[flow] " .. tostring(err)) end