minetest-menu/templates.lua

1227 lines
46 KiB
Lua

--[[ MARK: - Template Engine
Builds a formspec from the contents of a game-provided main menu file.
=================================================================================
Menu File Documentation
=================================================================================
Menu files are essentially just formspecs, but with special semantics and extended syntax.
First, menu files are made up of a number of dialog definitions. A dialog definition is
basically an HTML tag, where the tag name is the name of the dialog. For example this:
```
<main>
label[2,2;A label]
</main>
<other>
button[1,1;4,1;button;This is a button]
</other>
```
will define a dialog named 'main', with a label in it, and a dialog named 'other',
with a button in it. Every menu file must have a dialog named 'main', which serves
as the entry point of the game's menu.
Note that to prevent dialog definitions from conflicting with the contents of
hypertext[] elements, they must not have leading whitespace before the opening
delimiter.
Being able to define these other dialogs would be pretty pointless if they couldn't
be used for anything. Accordingly, you can use standard formspec actions, e.g. buttons,
to segue to a different dialog. To do this, set the action name to
'.show_dialog_<name>', where <name> is the name of the dialog you want to open.
You can also use '.overlay_dialog_<name>' to draw the target dialog on top of the
current dialog, and '.unoverlay_dialog' to hide it again.
There are several other action patterns that invoke special behavior:
-
- '.play': Start the game.
- '.play_with_world_named_<name>': Start the game on the specified world. If the
world in question does not exist, it will be created.
It is also possible to define metadata for the main menu. To do this, create a dialog
section named 'meta'. The contents of this section will then be parsed in the same way
as minetest.conf instead of being treated as a dialog.
Available metadata options are:
- 'enable_clouds': true/false (defaults to false)
Now, all this is good when we know exactly what content we want on the main menu.
But what if we want to render e.g. a list of worlds, or even a list of game modes?
This can be done using the @foreach construct. @foreach takes in a list of values,
the name of a JSON file, or a reference to a menu-exposed list.
The major advantage of a foreach loop, however, is interpolation. Inside a foreach
loop, the pattern '${<expression>}' will be replaces with the result of <expression>.
Inside an expression, you can reference variables as '@<name>'. Which variables are
available depends on the iterated list. In the case of a list literal, the current
item is exposed as a variable named @item, while for a JSON file, each key in the
file's top-level object will correspond to a variable. (Note that variable names may
only contain letters or numbers, even if mapped from a JSON file.)
The menu-provided lists are:
- @WORLDS: The list of worlds for this game. Exposes @name (the world name),
@path (the world's full absolute path), @gameid (the ID of the current game),
and @selected (whether the world is currently selected).
- @MODS: The list of installed mods that are not incompatible with this game.
Exposes @name, @title, @description, @author, @path, @depends, and @optional_depends.
- @WORLDMODS: The list of mods installed on the current world. No-op if no world
is selected.
Expressions also support rudimentary mathematical operations, namely addition (+),
'subtraction' (+-), multiplication (*), division (/), and exponentiation (^). Trying
to perform math on a non-tonumber()-able variable will treat the variable as 0.
As an example, this:
```
label[1,1;Worlds:]
@foreach:$WORLDS:name
label[1,${@i + 1};World named ${@name}]
@endforeach:name
@foreach:[one,two,three]:name2
label[4,${@i + 1};Item ${@i} is named: ${@item}]
@endforeach:name2
```
will:
- Create a "Worlds" label at 1,1;
- Create a label for every world with that world's name and a Y coordinate that
corresponds to the world's position in the list, plus 1 so these labels start
below the existing label.
- Create labels for "one", "two", and "three" that display the item's value and
its position in the list, with similarly increasing Y-coordinates.
Besides foreach loops, menu files also support conditionals. Conditionals are
written as '@if:<condition>:<name> ... @else:<name> ... @endif:<name>', where
<condition> is any expression. The conditional will be replaced with the contents
of its first block if the condition evaluates to non-zero, and the contents of
its second block otherwise. Note that the second block may not be omitted due to
how the parser works.
Example:
```
@if: @name = test :<name>
<formspec 1>
@else:<name>
<formspec 2>
@endif:<name>
```
Notes:
- The only uniqueness requirements for the name of a block is that it not be
the name of a statment of the same type contained in the body of that block.
This is so that the parser knows which `end` belongs to which block without
having to manage state.
Note: Because of the way the main menu works, image paths must be specified in full.
To make this non-painful, when referencing images use '$ASSET_PATH/<image name>'
instead of just the image name. $ASSET_PATH will be replaced with the actual path
to the game's menu/ directory, so images used by the main menu should be stored
there. You can use $ASSET_PATH in any context. Additionally, $DEFAULT_ASSET_PATH is
the path to the builtin assets folder, and $NO_IMAGE is the path to blank.png, in
case you need to stylistically unset a background image defined by a global style.
--]]
local zone = function() end
if not minetest then
zone = require("jit.zone")
minetest = {
formspec_escape = function() end,
hypertext_escape = function() end,
get_translator = function()
return function() end, function() end
end
}
local function basic_dump(o)
local tp = type(o)
if tp == "number" then
local s = tostring(o)
if tonumber(s) == o then
return s
end
-- Prefer an exact representation over a compact representation.
-- e.g. basic_dump(0.3) == "0.3",
-- but basic_dump(0.1 + 0.2) == "0.30000000000000004"
-- so the user can see that 0.1 + 0.2 ~= 0.3
return string.format("%.17g", o)
elseif tp == "string" then
return string.format("%q", o)
elseif tp == "boolean" then
return tostring(o)
elseif tp == "nil" then
return "nil"
elseif tp == "userdata" then
return tostring(o)
else
return string.format("<%s>", tp)
end
end
local keywords = {
["and"] = true,
["break"] = true,
["do"] = true,
["else"] = true,
["elseif"] = true,
["end"] = true,
["false"] = true,
["for"] = true,
["function"] = true,
["goto"] = true, -- Lua 5.2
["if"] = true,
["in"] = true,
["local"] = true,
["nil"] = true,
["not"] = true,
["or"] = true,
["repeat"] = true,
["return"] = true,
["then"] = true,
["true"] = true,
["until"] = true,
["while"] = true,
}
local function is_valid_identifier(str)
if not str:find("^[a-zA-Z_][a-zA-Z0-9_]*$") or keywords[str] then
return false
end
return true
end
--------------------------------------------------------------------------------
-- Dumps values in a line-per-value format.
-- For example, {test = {"Testing..."}} becomes:
-- _["test"] = {}
-- _["test"][1] = "Testing..."
-- This handles tables as keys and circular references properly.
-- It also handles multiple references well, writing the table only once.
-- The dumped argument is internal-only.
function dump2(o, name, dumped)
name = name or "_"
-- "dumped" is used to keep track of serialized tables to handle
-- multiple references and circular tables properly.
-- It only contains tables as keys. The value is the name that
-- the table has in the dump, eg:
-- {x = {"y"}} -> dumped[{"y"}] = '_["x"]'
dumped = dumped or {}
if type(o) ~= "table" then
return string.format("%s = %s\n", name, basic_dump(o))
end
if dumped[o] then
return string.format("%s = %s\n", name, dumped[o])
end
dumped[o] = name
-- This contains a list of strings to be concatenated later (because
-- Lua is slow at individual concatenation).
local t = {}
for k, v in pairs(o) do
local keyStr
if type(k) == "table" then
if dumped[k] then
keyStr = dumped[k]
else
-- Key tables don't have a name, so use one of
-- the form _G["table: 0xFFFFFFF"]
keyStr = string.format("_G[%q]", tostring(k))
-- Dump key table
t[#t + 1] = dump2(k, keyStr, dumped)
end
else
keyStr = basic_dump(k)
end
local vname = string.format("%s[%s]", name, keyStr)
t[#t + 1] = dump2(v, vname, dumped)
end
return string.format("%s = {}\n%s", name, table.concat(t))
end
-- This dumps values in a human-readable expression format.
-- If possible, the resulting string should evaluate to an equivalent value if loaded and executed.
-- For example, {test = {"Testing..."}} becomes:
-- [[{
-- test = {
-- "Testing..."
-- }
-- }]]
function dump(value, indent)
indent = indent or "\t"
local newline = indent == "" and "" or "\n"
local rope = {}
local write
do
-- Keeping the length of the table as a local variable is *much*
-- faster than invoking the length operator.
-- See https://gitspartv.github.io/LuaJIT-Benchmarks/#test12.
local i = 0
function write(str)
i = i + 1
rope[i] = str
end
end
local n_refs = {}
local function count_refs(val)
if type(val) ~= "table" then
return
end
local tbl = val
if n_refs[tbl] then
n_refs[tbl] = n_refs[tbl] + 1
return
end
n_refs[tbl] = 1
for k, v in pairs(tbl) do
count_refs(k)
count_refs(v)
end
end
count_refs(value)
local refs = {}
local cur_ref = 1
local function write_value(val, level)
if type(val) ~= "table" then
write(basic_dump(val))
return
end
local tbl = val
if refs[tbl] then
write(refs[tbl])
return
end
if n_refs[val] > 1 then
refs[val] = ("getref(%d)"):format(cur_ref)
write(("setref(%d)"):format(cur_ref))
cur_ref = cur_ref + 1
end
write("{")
if next(tbl) == nil then
write("}")
return
end
write(newline)
local function write_entry(k, v)
write(indent:rep(level))
write("[")
write_value(k, level + 1)
write("] = ")
write_value(v, level + 1)
write(",")
write(newline)
end
local keys = {string = {}, number = {}}
for k in pairs(tbl) do
local t = type(k)
if keys[t] then
table.insert(keys[t], k)
end
end
-- Write string-keyed entries
table.sort(keys.string)
for _, k in ipairs(keys.string) do
local v = val[k]
if is_valid_identifier(k) then
write(indent:rep(level))
write(k)
write(" = ")
write_value(v, level + 1)
write(",")
write(newline)
else
write_entry(k, v)
end
end
-- Write number-keyed entries
local len = 0
for i in ipairs(tbl) do
len = i
end
if #keys.number == len then -- table is a list
for _, v in ipairs(tbl) do
write(indent:rep(level))
write_value(v, level + 1)
write(",")
write(newline)
end
else -- table harbors arbitrary number keys
table.sort(keys.number)
for _, k in ipairs(keys.number) do
write_entry(k, tbl[k])
end
end
-- Write all remaining entries
for k, v in pairs(val) do
if not keys[type(k)] then
write_entry(k, v)
end
end
write(indent:rep(level - 1))
write("}")
end
write_value(value, 1)
return table.concat(rope)
end
function string.split(str, delim, include_empty, max_splits, sep_is_pattern)
delim = delim or ","
if delim == "" then
error("string.split separator is empty", 2)
end
max_splits = max_splits or -2
local items = {}
local pos, len = 1, #str
local plain = not sep_is_pattern
max_splits = max_splits + 1
repeat
local np, npe = string.find(str, delim, pos, plain)
np, npe = (np or (len+1)), (npe or (len+1))
if (not np) or (max_splits == 1) then
np = len + 1
npe = np
end
local s = string.sub(str, pos, np - 1)
if include_empty or (s ~= "") then
max_splits = max_splits - 1
items[#items + 1] = s
end
pos = npe + 1
until (max_splits == 0) or (pos > (len + 1))
return items
end
end
local fe = minetest.formspec_escape
local hte = minetest.hypertext_escape
template = {}
local operators = {
["or"] = {prec = 0, assoc = "left", kind = "binary"},
["and"] = {prec = 1, assoc = "left", kind = "binary"},
["=="] = {prec = 2, assoc = "left", kind = "binary"},
["<"] = {prec = 2, assoc = "left", kind = "binary"},
["<="] = {prec = 2, assoc = "left", kind = "binary"},
[">"] = {prec = 2, assoc = "left", kind = "binary"},
[">="] = {prec = 2, assoc = "left", kind = "binary"},
["+"] = {prec = 3, assoc = "left", kind = "binary"},
["-"] = {prec = 3, assoc = "left", kind = "binary"},
["*"] = {prec = 4, assoc = "left", kind = "binary"},
["/"] = {prec = 4, assoc = "left", kind = "binary"},
["%"] = {prec = 5, assoc = "left", kind = "binary"},
["not"] = {prec = 9, kind = "unary"},
["^"] = {prec = 10, assoc = "right", kind = "binary"},
-- Add more operators here, e.g. ["=="] = {prec = 0, assoc = "left"} for comparisons
}
function template.build_expr(expr)
-- First pass: Collect tokens into a flat list
local out = {}
local pos = 1
local len = #expr
while pos <= len do
local matched = false
-- Skip whitespace
local ws_start, ws_end = expr:find("^%s+", pos)
if ws_start then
pos = ws_end +1
matched = true
end
-- Strings
if not matched then
local str_start, str_end, str = expr:find('^"([^"]+)"', pos)
if not str_start then
str_start, str_end, str = expr:find("^'([^']+)'", pos)
end
if str_start then
out[#out +1] = {type = "string", value = str}
pos = str_end +1
matched = true
end
end
-- Numbers
if not matched then
local num_start, num_end, num = expr:find("^(%d+%.?%d*[eE]?[+-]?%d*)", pos)
if num_start then
out[#out +1] = {type = "number", value = tonumber(num)}
pos = num_end +1
matched = true
end
end
-- Operators
if not matched then
for op in pairs(operators) do
local op_start = pos
local op_end = pos +op:len() -1
if expr:sub(op_start, op_end) == op then
out[#out +1] = {type = "operator", op = op}
pos = op_end +1
matched = true
end
end
end
-- Grouping operators
if not matched then
local op_start, op_end, op = expr:find("^(%p)", pos)
if op_start then
if op == "," then
out[#out +1] = {type = "comma"}
elseif op == "." then
out[#out +1] = {type = "dot"}
elseif op == "(" then
out[#out +1] = {type = "lparen"}
elseif op == ")" then
out[#out +1] = {type = "rparen"}
elseif op == "[" then
out[#out +1] = {type = "lbracket"}
elseif op == "]" then
out[#out +1] = {type = "rbracket"}
else
goto aaa
end
pos = op_end +1
matched = true
::aaa::
end
end
-- Keywords
if not matched then
if expr:find("^true") then
out[#out +1] = {type = "bool", value = true}
pos = pos +3
matched = true
elseif expr:find("^false") then
out[#out +1] = {type = "bool", value = false}
pos = pos +4
matched = true
end
end
-- Identifiers
if not matched then
local id_start, id_end, id = expr:find("^([%a_][%w_]*)", pos)
if id_start then
local item = {type = "identifier", name = id}
pos = id_end +1
out[#out +1] = item
matched = true
end
end
if not matched then
pos = pos +1
end
end
-- Second pass: Fold the flat list into an expression tree
local tree = {}
local cursor = 1
local peek, consume, expect, parse_expr, parse_primary, parse_postfix
function peek()
return out[cursor]
end
function consume()
cursor = cursor +1
return out[cursor -1]
end
function expect(ty)
local t = consume()
if not t or t.type ~= ty then
error("Expected " .. ty .. " but got " .. (t and t.type or "nil").."(in expression `"..expr.."`)")
end
end
parse_expr = function(min_prec)
min_prec = min_prec or 0
-- Parse left operand: handle unary prefix operators
local left
local token = peek()
if token and token.type == "operator" then
local op_info = operators[token.op]
if op_info and op_info.kind == "unary" then
-- For unary prefix, use a high binding power
consume()
-- Recurse with high precedence for operand
local operand = parse_expr(op_info.prec)
left = {type = "unary_operator", op = token.op, expr = operand}
else
left = parse_primary()
end
else
left = parse_primary()
end
-- Apply postfix operators (index, call) - highest precedence
left = parse_postfix(left)
-- Handle binary operators (infix)
while true do
local token = peek()
if not token or token.type ~= "operator" then break end
local op_info = operators[token.op]
if not op_info or op_info.kind ~= "binary" or op_info.prec < min_prec then break end
consume()
local next_min_prec = op_info.prec
if op_info.assoc == "left" then
next_min_prec = next_min_prec + 1
end
-- For right-assoc, use >= so same prec binds right
local right = parse_expr(next_min_prec)
left = {type = "binary_operator", op = token.op, lhs = left, rhs = right}
end
return left
end
-- Parse postfix operators (index, call)
parse_postfix = function(left)
while peek() do
local t = peek()
if t.type == "lbracket" then
-- Indexing has high precedence
consume()
local key = parse_expr(0) -- Inside [] can have full expressions
expect("rbracket")
left = {type = "index", base = left, key = key}
elseif t.type == "lparen" then
-- Function call
consume()
local args = {}
while peek().type ~= "rparen" do
local e = parse_expr(0)
args[#args +1] = e
if peek().type == "comma" then consume() end
end
expect("rparen")
left = {type = "call", func = left, args = args}
elseif t.type == "dot" then
consume()
if peek().type == "identifier" then
left = {type = "index", base = left, key = {type = "string", value = consume().name}}
else -- Syntax error
end
else
break
end
end
return left
end
parse_primary = function()
local t = peek()
if t.type == "number" or t.type == "string" or t.type == "bool" or t.type == "list" then
consume()
return t
elseif t.type == "identifier" then
consume()
-- if peek() and peek().type == "lparen" then
-- consume()
-- local args = {}
-- while peek().type ~= "rparen" do
-- local e = parse_expr(0)
-- args[#args +1] = e
-- if peek().type == "comma" then consume() end
-- end
--
-- return {type = "call", func = t, args = args}
-- end
return t
elseif t.type == "lbracket" then
consume()
local list = {}
while peek().type ~= "rbracket" do
local e = parse_expr(0)
list[#list +1] = e
if peek().type == "comma" then consume() end
end
return {type = "list", value = list}
elseif t.type == "lparen" then
consume()
local e = parse_expr(0)
expect("rparen")
return e
else
print("Unexpected token: "..consume().type)
end
end
return parse_expr(0)
end
function template.build(menu)
local _id = 0
local function id()
_id = _id +1
return _id
end
local out = {["@root"] = {}}
local view_name = "main"
local block = {parent = {}, id="_root"}
local item = {parent = {}, id="_root"}
local offset = 1
local pos = 1
while true do
local matched = false
local stmt_start, stmt_end, stmt = menu:find("{%%%s*(.-)%s*%%}", pos)
local expr_start, expr_end, expr = menu:find("{{(.-)}}", pos)
local comment_start, comment_end, comment = menu:find("{#(.-)#}", pos)
-- Statements
if stmt_start and (not expr_start or stmt_start < expr_start) and (not comment_start or stmt_start < comment_start) then
block[#block +1] = menu:sub(offset, stmt_start -1)
local verb = stmt:match "^(%a+)"
if verb == "if" then
item = {
id = id(),
type = "if",
parent = block,
conditions = {{condition = template.build_expr(stmt:match "if%s*(.*)"), body = {}}}
}
block[#block +1] = item
block = item.conditions[1].body
block.parent = item
if not block.parent then print("block.parent set to nil: "..menu:sub(stmt_start -50, stmt_end +50)) end
elseif verb == "elseif" then
local x = {
condition = template.build_expr(stmt:match "if%s*(.*)"),
body = {}
}
item.conditions[#item.conditions +1] = x
block = x.body
block.parent = item
if not block.parent then print("block.parent set to nil: "..menu:sub(stmt_start -50, stmt_end +50)) end
elseif verb == "else" then
local x = {
condition = {type = "bool", value = true},
body = {}
}
item.conditions[#item.conditions +1] = x
block = x.body
block.parent = item
if not block.parent then print("block.parent set to nil: "..menu:sub(stmt_start -50, stmt_end +50)) end
elseif verb == "endif" then
block.parent = nil
block = item.parent
item.parent = nil
item = block.parent
elseif verb == "for" then
local var, iter = stmt:match "for%s*([%a_][%w_]*)%s*in%s*(.*)"
item = {
id = id(),
type = "for",
parent = block,
varname = var,
iterate = template.build_expr(iter),
body = {}
}
block[#block +1] = item
block = item.body
block.parent = item
elseif verb == "endfor" then
block.parent = nil
block = item.parent
item.parent = nil
item = block.parent
elseif verb == "macro" then
local name, params = stmt:match "macro%s*([%a_][%w_]*)%s*(.*)"
item = {
type = "macro",
parent = block,
name = name,
params = params:match("^%((.*)%)$"):split("%s*,%s*", false, -1, true),
body = {}
}
block[#block +1] = item
block = item.body
block.parent = item
elseif verb == "endmacro" then
block.parent = nil
block = item.parent
item.parent = nil
item = block.parent
elseif verb == "set" then
local name, expr = stmt:match "set %s*([%a_][%w_]*)%s*=?%s*(.*)"
if expr and expr ~= "" then
block[#block +1] = {type = "set", name = name, value = template.build_expr(expr)}
else
item = {
type = "set_block",
parent = block,
name = name,
value = {}
}
block[#block +1] = item
block = item.value
block.parent = item
end
elseif verb == "endset" then
block = item.parent
item.parent = nil
item = block.parent
block.parent = nil
elseif verb == "include" then
elseif verb == "view" then
view_name = stmt:match "view%s*([%a_][%w_]*)%s*" or "main"
table.insert_all(out["@root"], block)
item = {id = "root"}
block = {id = "root"}
elseif verb == "endview" then
out[view_name] = block
block = {}
end
offset = stmt_end +1
pos = stmt_end
matched = true
end
-- Interpolations
if expr_start and expr_start < (stmt_start or math.huge) and expr_start < (comment_start or math.huge) then
block[#block +1] = menu:sub(offset, expr_start -1)
block[#block +1] = template.build_expr(expr)
offset = expr_end +1
pos = expr_end
matched = true
end
-- Comments
if comment_start and comment_start < (stmt_start or math.huge) and comment_start < (expr_start or math.huge) then
block[#block +1] = menu:sub(offset, comment_start -1)
offset = comment_end +1
pos = comment_end
matched = true
end
if not matched then
break
end
end
block[#block +1] = menu:sub(offset)
out[#out +1] = block
-- Attach a reference to the root scope in each view.
for k, v in pairs(out) do
if k ~= "@root" then
v.root = out["@root"]
end
end
return out
end
function template.evaluate_expr(expr, vars)
assert(expr, "No expression given!")
if expr.type == "binary_operator" then
assert(expr.lhs and expr.rhs, "Binary operator missing an operand")
if expr.op == "+" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) + (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "*" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) * (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "-" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) - (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "/" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) / (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "%" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) % (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "or" then
return template.evaluate_expr(expr.lhs, vars) or template.evaluate_expr(expr.rhs, vars)
elseif expr.op == "and" then
return template.evaluate_expr(expr.lhs, vars) and template.evaluate_expr(expr.rhs, vars)
elseif expr.op == "==" then
return template.evaluate_expr(expr.lhs, vars) == template.evaluate_expr(expr.rhs, vars)
elseif expr.op == ">" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) > (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == ">" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) >= (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "<" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) < (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
elseif expr.op == "<=" then
return (tonumber(template.evaluate_expr(expr.lhs, vars)) or 0) <= (tonumber(template.evaluate_expr(expr.rhs, vars)) or 0)
else
return template.evaluate_expr(expr.lhs, vars)
end
elseif expr.type == "unary_operator" then
assert(expr.expr, "Invalid unary expression")
if expr.op == "not" then
return not template.evaluate_expr(expr.expr, vars)
else
return template.evaluate_expr(expr.expr, vars)
end
elseif expr.type == "identifier" then
return vars[expr.name]
elseif expr.type == "index" then
assert(expr.base and expr.key, "Invalid indexing")
return template.evaluate_expr(expr.base, vars)[template.evaluate_expr(expr.key, vars)]
elseif expr.type == "call" then
assert(expr.func, "Invalid function ref")
local fn = template.evaluate_expr(expr.func, vars)
if type(fn) == "function" then
local args = {}
for _, x in pairs(expr.args) do
assert(x, "Invalid argument")
args[#args +1] = template.evaluate_expr(x, vars)
end
return fn(unpack(args))
end
elseif expr.type == "list" then
local out = {}
for i, x in ipairs(expr.value) do
assert(x, "Invalid list item")
out[i] = template.evaluate_expr(x, vars)
end
return out
else
return expr.value or false
end
end
local S, PS = minetest.get_translator()
local env = {
S = S,
translate = S,
PS = S,
translate_n = PS,
fe = fe,
formspec_escape = fe,
hte = hte,
hypertext_escape = hte,
DEFAULT_ASSETS = assets,
floor = math.floor,
ceil = math.ceil,
round = math.round,
min = math.min,
max = math.max,
-- Convert arguments to a formspec-safe representation that can be evaluated in on_reveive_fields
action = function(type, ...)
local args = {...}
if type == "set" then
local name = args[1]
return ".set_|"..fe(name).."|_to_"..fe(minetest.write_json(args[2]))
end
return out
end,
cycle = function(idx, ...)
local options = {...}
return options[((idx -1) %#options) +1]
end,
upper = function(str)
return string.upper(str)
end,
len = function(x)
return #x
end,
range = function(min, max, step)
local out = {}
if not step then step = 1 end
for i = min, max, step do
out[#out +1] = i
end
return out
end
}
function template.evaluate(dialog, vars, depth)
-- Ensure that variables from `env` remain accessible even when `vars` has its own metatable set.
local out
if not depth then
local real_vars = vars
vars = setmetatable({}, {
__index = function(tbl, k)
return real_vars[k] or env[k]
end
})
depth = 0
out = dialog.root and {template.evaluate(dialog.root, vars, depth +1)} or {}
else
out = {}
end
for _, item in ipairs(dialog) do
if type(item) == "string" then
out[#out +1] = item
else
if item.type == "if" then
for _, cond in ipairs(item.conditions) do
if template.evaluate_expr(cond.condition, vars) then
out[#out +1] = template.evaluate(cond.body, vars, depth +1)
break
end
end
elseif item.type == "for" then
local iterable = template.evaluate_expr(item.iterate, vars)
if type(iterable) == "table" then
for i, entry in ipairs(iterable) do
local vars2 = {
index = i,
index0 = i -1,
length = #iterable,
first = i == 1,
depth = 1,
previtem = iterable[i -1],
nextitem = iterable[i +1],
cycle = function(...)
return env.cycle(i, ...)
end
}
vars2.last = i == vars2.length
vars2.revindex = vars2.length -vars2.index
vars2.revindex0 = vars2.length -vars2.index0
if vars.loop and vars.loop.depth then
vars.depth = vars.loop.depth +1
end
-- Save the original values of these variables, to simulate a closure.
local prev_var = vars[item.varname]
local prev_loop = vars.loop
vars[item.varname] = entry
vars.loop = vars2
out[#out +1] = template.evaluate(item.body, vars, depth +1)
-- Restore the variables we set to their original values.
vars[item.varname] = prev_var
vars.loop = prev_loop
end
end
elseif item.type == "set" then
vars[item.name] = template.evaluate_expr(item.value, vars)
elseif item.type == "set_block" then
vars[item.name] = template.evaluate(item.value, vars, depth +1)
elseif item.type == "macro" then
vars[item.name] = function(...)
local argv = {...}
local args = {}
for i, x in ipairs(argv) do
args[item.params[i]] = x
end
return template.evaluate(item.body, setmetatable(args, {__index = vars}), depth +1)
end
else
out[#out + 1] = tostring(template.evaluate_expr(item, vars))
end
end
end
return table.concat(out)
end
function template.test()
-- local profile = require("jit.profile")
-- local profile_data = {}
-- local function profile_callback(thread, samples, vmstate)
-- profile_data[#profile_data +1] = {profile.dumpstack(thread, "pF;", -100), vmstate,
-- " ", samples, "\n"}
-- end
-- Start profiling
-- profile.start("li0.1", profile_callback)
local ast = template.build [[
{% macro field(x, y, w, h, name, value) %}
image[{{ x }},{{ y }};{{ w }},{{ h }};{{ DEFAULT_ASSETS }}btn_bg_2_light.png;8,8]
field[{{ x + 0.1 }},{{ y }};{{ w - 0.2 }},{{ h }};{{ name }};;{{ value }}]
{% endmacro %}
{% view main %}
{% if selected_world %}
{% set list_width = WIDTH * 0.3 %}
{% else %}
{% set list_width = WIDTH * 0.8 %}
{% endif %}
{{ field(2, 2, 4, 0.75, 'test', '') }}
{% set col = 12 % 3 %}
{{ col > 0 and 'yes' or 'no' }}
{{ action("set", "q", [1, 2, 3]) }}
scroll_container[0,0;{{ foo }},{{ foo }};worldscroll;vertical;;1,0]
{#
image[{{ WIDTH * 0.1 - 0.1 }},{{ HEIGHT * 0.1 - 0.1}};{{ WIDTH * 0.8 + 0.2 }},{{ HEIGHT * 0.8 + 0.2 }};{{ DEFAULT_ASSETS }}bg_translucent.png;8,8]
scroll_container[{{ WIDTH * 0.1 }},{{ HEIGHT * 0.1 }};{{ list_width }},{{ HEIGHT * 0.8 }};worldscroll;vertical;;0,0]
{% for world in get_worlds() %}
style[.select_world_{{ world.path }};bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_world_{{ world.path }}:hovered;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_world_{{ world.path }}:hovered+focused;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
button[0,{{ loop.index0 * 0.5}};{{ list_width }},0.5;.select_world_{{ world.path }};{{ world.name }}]
{% endfor %}
scroll_container_end[]
{% if selected_world %}
box[{{ WIDTH * 0.4 - 0.05 }},{{ HEIGHT * 0.1 - 0.1 }};0.1,{{ HEIGHT * 0.8 + 0.2 }};#292d2fff]
scroll_container[{{ WIDTH * 0.4 + 0.05 }},{{ HEIGHT * 0.1 }};{{ WIDTH * 0.8 - 0.05 }},{{ HEIGHT * 0.8 }};worlconfigscroll;vertical;;0,0]
{% set j = 0 %}
{% if setting_damage %}
{% if damage_enabled %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_filled.png;.set_damage_enabled_to_false;]
{% else %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_empty.png;.set_damage_enabled_to_true;]
{% endif %}
label[0.5,{{ j + 0.25 }};Damage]
{% set j = j + 0.5 %}
{% endif %}
{% if setting_creative %}
{% if creative_enabled %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_filled.png;.set_creative_enabled_to_false;]
{% else %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_empty.png;.set_creative_enabled_to_true;]
{% endif %}
label[0.5,{{ j + 0.25 }};Creative]
{% set j = j + 0.5 %}
{% endif %}
{% set play_str = "Play" %}
{% if setting_server %}
{% if server_enabled %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_filled.png;.set_server_enabled_to_false;]
{% set play_str = "Host server" %}
{% else %}
image_button[0,{{ j }};0.5,0.5;{{ DEFAULT_ASSETS }}checkbox_empty.png;.set_server_enabled_to_true;]
{% set play_str = "Play" %}
{% endif %}
label[0.5,{{ j + 0.25 }};Server]
{% set j = j + 0.5 %}
{% endif %}
{% set j = j + 1 %}
button[{{ WIDTH * 0.05 }},{{ j }};{{ WIDTH * 0.4 }},0.75;.overlay_view_modconfig;Configure mods]
button[{{ WIDTH * 0.05 }},{{ j + 1 }};{{ WIDTH * 0.4 }},0.75;.play;{{ play_str }}]
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;worldconfigscroll;]
{% endif %}
scrollbaroptions[arrows=hide]
scrollbar[-800,6;0,2;vertical;worldscroll;]
{% endview %}
{% view modconfig %}
image[{{ WIDTH * 0.05 - 0.1 }},{{ HEIGHT * 0.05 - 0.1 }};{{ WIDTH * 0.4 + 0.2 }},{{ HEIGHT * 0.9 - 0.75 }};{{ DEFAULT_ASSETS }}btn_bg.png;8,8]
hypertext[{{ WIDTH * 0.05 }},{{ HEIGHT * 0.05 }};{{ WIDTH * 0.4 }},0.75;;<global valign=middle halign=center><b>Available mods</b>]
scroll_container[{{ WIDTH * 0.05 }},{{ HEIGHT * 0.05 + 0.75 }};{{ WIDTH * 0.4 }},{{ HEIGHT * 0.9 - 1.7 }};modsscroll;vertical;;0,0]
{% for mod in get_mods() %}
style[.select_mod_{{ mod.name }};bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_mod_{{ mod.name }}:hovered;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_mod_{{ mod.name }}:hovered+focused;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
button[0,{{ loop.index0 * 0.5 }};{{ WIDTH * 0.4 }},0.5;.select_mod_{{ mod.name }};{{ mod.name }}]
{% endfor %}
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;modsscroll;]
image[{{ WIDTH * 0.55 - 0.1 }},{{ HEIGHT * 0.05 - 0.1 }};{{ WIDTH * 0.4 + 0.2 }},{{ HEIGHT * 0.9 - 0.75 }};{{ DEFAULT_ASSETS }}btn_bg.png;8,8]
hypertext[{{ WIDTH * 0.55 }},{{ HEIGHT * 0.05 }};{{ WIDTH * 0.4 }},0.75;;<global valign=middle halign=center><b>Enabled mods</b>]
scroll_container[{{ WIDTH * 0.55 }},{{ HEIGHT * 0.05 + 0.75 }};{{ WIDTH * 0.4 }},{{ HEIGHT * 0.9 - 1.7 }};worldmodsscroll;vertical;;0,0]
{% for mod in get_world_mods() %}
{% if not mod.game_provided %}
style[.select_mod_{{ mod.name }};bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_mod_{{ mod.name }}:hovered;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
style[.select_mod_{{ mod.name }}:hovered+focused;bgimg={{ DEFAULT_ASSETS }}white.png;bgimg_middle=0;bgcolor={{ loop.cycle('#373530', '#403e39') }}]
button[0,{{ loop.index0 * 0.5 }};{{ WIDTH * 0.4 }},0.5;.select_mod_{{ mod.name }};{{ mod.name }}]
{% endif %}
{% endfor %}
scroll_container_end[]
scrollbar[-800,6;0,2;vertical;worldmodsscroll;]
image_button[{{ WIDTH * 0.45 }},{{ HEIGHT * 0.5 - WIDTH * 0.1 }};{{ WIDTH * 0.1 }},{{ WIDTH * 0.1 }};{{ DEFAULT_ASSETS }}arrow_right.png;.add_mod_to_world;]
image_button[{{ WIDTH * 0.45 }},{{ HEIGHT * 0.5 }};{{ WIDTH * 0.1 }},{{ WIDTH * 0.1 }};{{ DEFAULT_ASSETS }}arrow_left.png;.remove_mod_from_world;]
tooltip[.add_mod_to_world;Add mod to world;#444;#aaa]
tooltip[.remove_mod_from_world;Remove mod from world;#444;#aaa]
#}
button[{{ WIDTH * 0.1 }},{{ HEIGHT * 0.95 - 0.75 }};{{ WIDTH * 0.4 }},0.75;.unoverlay_view;Cancel]
button[{{ WIDTH * 0.5 }},{{ HEIGHT * 0.95 - 0.75 }};{{ WIDTH * 0.4 }},0.75;.unoverlay_view;Confirm]
{% endview %}
{% view addworld %}
{% endview %}
]]
-- print(dump(ast, " "))
print(template.evaluate(ast.main, setmetatable({z = false, bar = true, foo = 3, blah = "test"}, {__index = {q = "hello"}})))
-- profile.stop()
--
-- print("Profiler data: "..dump(profile_data, " "))
--
-- -- Analyze results
-- for stack, samples in pairs(profile_data) do
-- print(string.format("%s: %d samples", stack, samples))
-- end
end
local time = os.clock()
--for _ = 1, 1000 do
template.test()
--end
print("Execution finished in: "..(os.clock() -time))