--
-- 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