forked from your-land-mirror/minetest-flow
613 lines
20 KiB
Lua
613 lines
20 KiB
Lua
--
|
|
-- Flow: Luanti formspec layout engine
|
|
--
|
|
-- Copyright © 2022-2025 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 <https://www.gnu.org/licenses/>.
|
|
--
|
|
|
|
local DEBUG_MODE = false
|
|
flow = {}
|
|
local modpath = core.get_modpath("flow")
|
|
|
|
local apply_padding, get_and_fill_in_sizes, set_current_lang,
|
|
DEFAULT_SPACING, LABEL_HEIGHT, invisible_elems =
|
|
dofile(modpath .. "/layout.lua")
|
|
|
|
local expand = assert(loadfile(modpath .. "/expand.lua"))(
|
|
DEFAULT_SPACING, LABEL_HEIGHT, get_and_fill_in_sizes, invisible_elems
|
|
)
|
|
|
|
local parse_callbacks = dofile(modpath .. "/input.lua")
|
|
|
|
assert(loadfile(modpath .. "/widgets.lua"))(
|
|
DEFAULT_SPACING, get_and_fill_in_sizes
|
|
)
|
|
|
|
-- Renders the GUI into hopefully valid AST
|
|
-- This won't fill in names
|
|
local function render_ast(node, embedded)
|
|
node.padding = node.padding or 0.3
|
|
local w, h = apply_padding(node, 0, 0)
|
|
expand(node)
|
|
local res = {
|
|
formspec_version = 7,
|
|
{type = "size", w = w, h = h},
|
|
}
|
|
|
|
if not embedded then
|
|
if node.window_position then
|
|
res[#res + 1] = {
|
|
type = "position",
|
|
x = node.window_position.x,
|
|
y = node.window_position.y,
|
|
}
|
|
node.window_position = nil
|
|
end
|
|
if node.window_anchor then
|
|
res[#res + 1] = {
|
|
type = "anchor",
|
|
x = node.window_anchor.x,
|
|
y = node.window_anchor.y,
|
|
}
|
|
node.window_anchor = nil
|
|
end
|
|
if node.window_padding then
|
|
res[#res + 1] = {
|
|
type = "padding",
|
|
x = node.window_padding.x,
|
|
y = node.window_padding.y,
|
|
}
|
|
node.window_padding = nil
|
|
end
|
|
if node.no_prepend 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 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
|
|
end
|
|
|
|
res[#res + 1] = node
|
|
|
|
return res
|
|
end
|
|
|
|
local Form = {}
|
|
|
|
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 style_type = node.name == "_#" or not node.name
|
|
local base_selector = style_type and node.type or node.name
|
|
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 = style_type and "style_type" or "style",
|
|
selectors = selectors,
|
|
props = props,
|
|
})
|
|
|
|
if style_type 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
|
|
if not node.close_on_enter then
|
|
table.insert(tree, i, {
|
|
type = 'field_close_on_enter',
|
|
name = node.name,
|
|
close_on_enter = false,
|
|
})
|
|
end
|
|
|
|
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.
|
|
local gui = flow.widgets
|
|
function Form:_render(player, ctx, formspec_version, id1, embedded, lang_code)
|
|
local used_ctx_vars = {}
|
|
set_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, on_key_enters =
|
|
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
|
|
|
|
set_current_lang(nil)
|
|
|
|
return tree, {
|
|
self = self,
|
|
callbacks = callbacks,
|
|
btn_callbacks = btn_callbacks,
|
|
saved_fields = saved_fields,
|
|
on_key_enters = on_key_enters,
|
|
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)
|
|
if not core.global_exists("hud_fs") then
|
|
error("[flow] Form:show_hud() requires the hud_fs mod to be " ..
|
|
"installed!", 2)
|
|
end
|
|
|
|
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 on_key_enter callbacks
|
|
if fields.key_enter and form_info.on_key_enters then
|
|
local callback = form_info.on_key_enters[fields.key_enter_field]
|
|
if callback then
|
|
-- Enter callbacks and button fields can't be sent at the same
|
|
-- time (except from a hacked client), so make sure they aren't
|
|
-- both processed at once by returning now.
|
|
return callback(player, ctx) or redraw_fs
|
|
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)
|
|
|
|
if core.is_singleplayer() then
|
|
local S = core.get_translator("flow")
|
|
|
|
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
|