imfs/init.lua
2026-01-23 19:17:20 -05:00

1168 lines
34 KiB
Lua

local fe = minetest.formspec_escape
local hte = minetest.hypertext_escape
local ctx
local contexts = {}
local inventories = {}
local theme = {}
-- MARK: Helpers
-- Detects percentage units andconverts them into an appropriate number (based on `ref`).
local function resolve_layout_units(value, ref)
if type(value) == "string" then
local num = tonumber(value)
if num then return num end
local percent = value:match "(%d+)%%"
if ref and percent then
return tonumber(percent) /100 *ref
end
error("Malformed layout units: "..value)
else
return value
end
end
-- The same as resolve_layout_units, but allows (and notifies of) flex units.
local function resolve_flex_layout_units(value, ref)
if type(value) == "string" then
local num = tonumber(value)
if num then return num end
local percent = value:match "(%d+)%%"
if ref and percent then
return tonumber(percent) /100 *ref
end
local flex = value:match "(%d+)x"
if flex then
return tonumber(flex), "flex"
end
error("Malformed layout units: "..value)
else
return value
end
end
-- This should be a method on any element classes that support inline styling.
local function add_elem_style(e, state, props)
-- Allow omitting the state while keeping the properties the last argument.
if not props then
props = state
state = "default"
end
e._styles[state] = imfs.style(e.__id..":"..state, props, true)
return e
end
-- This should be a method on any element classes that support tooltips.
local function add_elem_tooltip(e, text, bgcolor, txtcolor)
-- For named elements, prefer name-associated tooltips.
if e.__id then
imfs._named_tooltip(e.__id, text, bgcolor, txtcolor)
-- For other elements, if they have a size, use an area tooltip instead.
elseif e.w then
imfs.tooltip(e.x, e.y, e.w, e.h, text, bgcolor, txtcolor)
end
return e
end
local function new_id()
return "_"..minetest.get_us_time().."_"..math.random(1, 100000)
end
-- MARK: Data structures
local observers = {}
local state = {
observers = observers,
get = function(e)
local observer = observers[#observers]
if observer then
observer[e] = true -- Add this base state as a dep (using table for set-like uniqueness)
end
return e._val
end,
set = function(e, val)
if val == nil then error "!" end
e._old_val = e._val
e._val = val
for _, x in pairs(e._getters) do
x:update()
end
end,
__call = function(e, val)
if val == nil then -- Getter
return e:get()
else -- Setter
return e:set(val)
end
end
}
state.__index = state
setmetatable(state, {
__call = function(_, val)
local e = {_getters = {}, _val = val}
setmetatable(e, state)
return e
end
})
local DerivedState = {
get = function(e)
if e._stale then
e:update()
end
return e._val
end,
update = function(e)
for dep in pairs(e._deps) do
dep._getters[e.__id] = nil
e._deps[dep] = nil
end
local tracker = e._deps
table.insert(observers, tracker)
local val = e._fn()
table.remove(observers)
e._val = val
for dep in pairs(e._deps) do
dep._getters[e.__id] = e
end
-- Don't propagate updates on initial computation to avoid stack overflow.
if not e._stale then
for _, x in pairs(e._getters) do
x:update()
end
end
e._stale = false
end,
__call = function(e)
return e:get()
end
}
DerivedState.__index = DerivedState
function derive(fn)
return setmetatable({_fn = fn, _stale = true, _getters = {}, _deps = {}, __id = minetest.get_us_time()}, DerivedState)
end
local function get(e, x)
local item = e[x]
local mt = getmetatable(item)
if mt == state or mt == DerivedState then
return fe(item())
end
return fe(item)
end
-- MARK: Elements
local fs_style = {
render = function(e)
local props = {}
for k, v in pairs(e.props) do
props[#props +1] = ";"
props[#props +1] = k
props[#props +1] = "="
props[#props +1] = tostring(v)
end
return string.format("style[%s%s]", e.name, table.concat(props))
end
}
fs_style.__index = fs_style
setmetatable(fs_style, {
__call = function(_, name, props, internal)
local e = {name = name, props = props}
setmetatable(e, fs_style)
if not internal then table.insert(ctx, e) end
return e
end
})
local fs_tooltip = {
render = function(e, x, y, w, h)
if e._name then
return string.format("tooltip[%s;%s;%s;%s]", e._name, get(e, "text"), get(e, "bgcolor"), get(e, "txtcolor"))
else
return string.format(
"tooltip[%f,%f;%f,%f;%s;%s;%s]",
x or get(e, "x"), y or get(e, "y"),
w or get(e, "w"), h or get(e, "h"),
get(e, "text"),
get(e, "bgcolor"),
get(e, "txtcolor")
)
end
end
}
fs_tooltip.__index = fs_tooltip
setmetatable(fs_tooltip, {
__call = function(_, x, y, w, h, text, bgcolor, txtcolor)
local e = {x = x, y = y, w = w, h = h, text = text, bgcolor = bgcolor or "", txtcolor = txtcolor or ""}
setmetatable(e, fs_tooltip)
table.insert(ctx, e)
return e
end
})
local function fs_named_tooltip(name, text, bgcolor, txtcolor)
local e = {_name = name, text = text, bgcolor = bgcolor or "", txtcolor = txtcolor or ""}
setmetatable(e, fs_tooltip)
table.insert(ctx, e)
return e
end
local fs_label = {
render = function(e)
return string.format("label[%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "txt"))
end
}
fs_label.__index = fs_label
setmetatable(fs_label, {
__call = function(_, x, y, txt)
local e = {x = x, y = y, txt = txt}
setmetatable(e, fs_label)
table.insert(ctx, e)
return e
end
})
local fs_arealabel = {
render = function(e)
if e._scrollable then
return string.format("textarea[%f,%f;%f,%f;;;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "txt"))
else
return string.format("label[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "txt"))
end
end,
scrollable = function(e)
e._scrollable = true
return e
end,
tooltip = add_elem_tooltip,
}
fs_arealabel.__index = fs_arealabel
setmetatable(fs_arealabel, {
__call = function(_, x, y, w, h, txt)
local e = {x = x, y = y, w = w, h = h, txt = txt}
setmetatable(e, fs_arealabel)
table.insert(ctx, e)
return e
end
})
local fs_hypertext = {
render = function(e)
return string.format("hypertext[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), hte(get(e, "txt")))
end,
onaction = function(e, fn)
ctx._events.on_click[e.__id] = fn
return e
end,
tooltip = add_elem_tooltip,
}
fs_hypertext.__index = fs_hypertext
setmetatable(fs_hypertext, {
__call = function(_, x, y, w, h, txt)
local e = {x = x, y = y, w = w, h = h, txt = txt}
e.__id = "_"..minetest.get_us_time().."_"..math.random(1, 100000)
setmetatable(e, fs_hypertext)
table.insert(ctx, e)
return e
end
})
local fs_box = {
render = function(e, x, y, w, h)
return string.format("box[%f,%f;%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "bg"))
end,
tooltip = add_elem_tooltip,
}
fs_box.__index = fs_box
setmetatable(fs_box, {
__call = function(_, x, y, w, h, bg)
local e = {x = x, y = y, w = w, h = h, bg = bg}
setmetatable(e, fs_box)
table.insert(ctx, e)
return e
end
})
local fs_image = {
render = function(e, x, y, w, h)
if e._anim_frames then
return string.format(
"animated_image[%f,%f;%f,%f;%s;%s;%f;%f;%f;%s]",
x or get(e, "x"), y or get(e, "y"),
w or get(e, "w"), h or get(e, "h"),
e.__id,
get(e, "texture"),
get(e, "_anim_frames"),
get(e, "_anim_duration"),
get(e, "_anim_start"),
get(e, "middle")
)
else
return string.format("image[%f,%f;%f,%f;%s;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "texture"), get(e, "middle"))
end
end,
animated = function(e, frames, duration, start)
-- We only need an ID if the image is animated and must persist its state.
e.__id = e.__id or new_id()
e._anim_frames = frames
e._anim_duration = duration or 50
e._anim_start = start or 1
return e
end,
tooltip = add_elem_tooltip,
}
fs_image.__index = fs_image
setmetatable(fs_image, {
__call = function(_, x, y, w, h, texture, middle)
local e = {x = x, y = y, w = w, h = h, texture = texture, middle = middle or ""}
setmetatable(e, fs_image)
table.insert(ctx, e)
return e
end
})
local fs_item_image = {
render = function(e, x, y, w, h)
return string.format("item_image[%f,%f;%f,%f;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "item"))
end,
tooltip = add_elem_tooltip,
}
fs_item_image.__index = fs_item_image
setmetatable(fs_item_image, {
__call = function(_, x, y, w, h, item)
local e = {x = x, y = y, w = w, h = h, item = item}
setmetatable(e, fs_item_image)
table.insert(ctx, e)
return e
end
})
local fs_model = {
render = function(e, x, y, w, h)
local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
out[#out +1] = string.format(
"model[%f,%f;%f,%f;%s;%s;%s;%f;%s;%s;%s;%f]",
x or get(e, "x"), y or get(e, "y"),
w or get(e, "w"), h or get(e, "h"),
e.__id,
get(e, "mesh"), get(e, "textures"),
get(e, "_rotation") or "", get(e, "_continuous") or "",
get(e, "_mouse_control"),
get(e, "_animation") or "", get(e, "_animation_speed")
)
return table.concat(out)
end,
rotation = function(e, rot, continuous)
e._rotation = rot
e._continuous = continuous
end,
mouse_control = function(e, mouse_control)
e._mouse_control = mouse_control ~= false and true or false
end,
animated = function(frames, speed)
e._animation = frames
e._animation_speed = speed or 1
end,
style = add_elem_style,
tooltip = add_elem_tooltip,
}
fs_model.__index = fs_model
setmetatable(fs_model, {
__call = function(_, x, y, w, h, mesh, textures)
local e = {x = x, y = y, w = w, h = h, mesh = mesh, textures = textures, mouse_control = true}
e.__id = new_id()
setmetatable(e, fs_model)
table.insert(ctx, e)
return e
end
})
local fs_button = {
render = function(e, x, y, w, h)
local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
if e._item then
out[#out +1] = string.format("item_image_button[%f,%f;%f,%f;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_item"), e.__id, get(e, "label"))
elseif e._image then
if e._image_pressed then
-- We never specify noclip or border here. That's a job for styles.
out[#out +1] = string.format("image_button[%f,%f;%f,%f;%s;%s;;;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label"), get(e, "_image_pressed"))
else
out[#out +1] = string.format("image_button%s[%f,%f;%f,%f;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "_image"), e.__id, get(e, "label"))
end
else
out[#out +1] = string.format("button%s[%f,%f;%f,%f;%s;%s]", e._exit and "_exit" or "", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), e.__id, get(e, "label"))
end
return table.concat(out)
end,
image = function(e, img, pressed_img)
e._image = img
e._image_pressed = pressed_img
end,
item_image = function(e, item)
e._item = item
end,
exit = function(e)
e._exit = true
end,
onclick = function(e, fn)
ctx._events.on_click[e.__id] = fn
return e
end,
style = add_elem_style,
tooltip = add_elem_tooltip,
}
fs_button.__index = fs_button
setmetatable(fs_button, {
__call = function(_, x, y, w, h, label)
local e = {x = x, y = y, w = w, h = h, label = label, _styles = {}}
e.__id = new_id()
setmetatable(e, fs_button)
table.insert(ctx, e)
return e
end
})
local fs_checkbox = {
render = function(e, x, y)
return string.format("checkbox[%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), e.__id, get(e, "label"), get(e, "checked") and "true" or "false")
end,
onchange = function(fn)
ctx._events.on_change[e.__id] = fn
return e
end,
tooltip = add_elem_tooltip,
}
fs_checkbox.__index = fs_checkbox
setmetatable(fs_checkbox, {
__call = function(_, x, y, label, checked)
local e = {x = x, y = y, label = label or "", checked = checked or label or false}
e.__id = new_id()
setmetatable(e, fs_checkbox)
table.insert(ctx, e)
return e
end
})
local fs_list = {
render = function(e, x, y, w, h)
w = w or get(e, "w")
h = h or get(e, "h")
-- These lines will cause `w` and `h` to be interpreted as formspec coordinates rather than numbers of slots.
-- w = w -((w -1) /4)
-- h = h -((h -1) /4)
return string.format("list[%s;%s;%f,%f;%d,%d;%s]", get(e, "location"), get(e, "list"), x or get(e, "x"), y or get(e, "y"), w, h, get(e, "start"))
end
}
fs_list.__index = fs_list
setmetatable(fs_list, {
__call = function(_, x, y, w, h, location, list, start)
local e = {x = x, y = y, w = w, h = h, location = location or "current_player", list = list or "main", start = start or ""}
e.__id = new_id()
setmetatable(e, fs_list)
table.insert(ctx, e)
return e
end
})
local fs_inventory = fs_list
local fs_listring = {
render = function(e)
if e.location then
return string.format("listring[%s;%s]", e.location, e.list)
else
return "listring[]"
end
end,
}
fs_listring.__index = fs_listring
setmetatable(fs_listring, {
__call = function(_, location, list)
local e = location and list and {location = location, list = list} or {}
setmetatable(e, fs_listring)
table.insert(ctx, e)
return e
end
})
local fs_field = {
render = function(e, x, y, w, h)
local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
x = x or get(e, "x")
y = y or get(e, "y")
w = w or get(e, "w")
h = h or get(e, "h")
if e._password then
out[#out +1] = string.format("pwdfield[%f,%f;%f,%f;%s;%s]", x, y, w, h, e.__id, get(e, "label"))
if not e._close_on_enter then
out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id)
end
else
out[#out +1] = string.format("%s[%f,%f;%f,%f;%s;%s;%s]", e._textarea and "textarea" or "field", x, y, w, h, e.__id, get(e, "label"), get(e, "value"))
if not e._textarea and not e._close_on_enter then
out[#out +1] = string.format("field_close_on_enter[%s;false]", e.__id)
end
end
return table.concat(out)
end,
onenter = function(e, fn)
ctx._events.on_enter[e.__id] = fn
return e
end,
onchange = function(e, fn)
ctx._events.on_change[e.__id] = fn
return e
end,
close_on_enter = function(e)
e._close_on_enter = true
return e
end,
multiline = function(e)
e._textarea = true
return e
end,
password = function(e)
e._password = true
return e
end,
style = add_elem_style,
tooltip = add_elem_tooltip,
}
fs_field.__index = fs_field
setmetatable(fs_field, {
__call = function(_, x, y, w, h, label, value)
local e = {x = x, y = y, w = w, h = h, label = label or "", value = value or label or "", _styles = {}}
e.__id = new_id()
setmetatable(e, fs_field)
table.insert(ctx, e)
return e
end
})
local function fs_textarea(...)
return fs_field(...)
:multiline()
end
local fs_scrollbar = {
render = function(e, x, y, w, h)
local out = {}
if e._options then
out[#out +1] = "scrollbaroptions["
local first = true
for k, v in pairs(options) do
if not first then
out[#out +1] = ";"
end
out[#out +1] = k
out[#out +1] = "="
out[#out +1] = v
first = nil
end
out[#out +1] = "]"
end
out[#out +1] = string.format("scrollbar[%f,%f;%f,%f;%s;%s;%s]", x or get(e, "x"), y or get(e, "y"), w or get(e, "w"), h or get(e, "h"), get(e, "orientation"), e.__id, get(e, "value"))
return table.concat(out)
end,
options = function(e, props)
e._options = props
return e
end,
onchange = function(e, fn)
ctx._events.on_scrollbar_event[e.__id] = fn
return e
end,
style = add_elem_style,
tooltip = add_elem_tooltip,
}
fs_scrollbar.__index = fs_scrollbar
setmetatable(fs_scrollbar, {
__call = function(_, x, y, w, h, orientation, value)
local e = {x = x, y = y, w = w, h = h, orientation = orientation or "vertical", value = value or "", _styles = {}}
e.__id = "_"..minetest.get_us_time().."_"..math.random(1, 100000)
setmetatable(e, fs_scrollbar)
table.insert(ctx, e)
return e
end
})
local fs_scroll_container = {
render = function(e, x, y, w, h, orient, fac, pad)
x = x or get(e, "x")
y = y or get(e, "y")
w = w or get(e, "w")
h = h or get(e, "h")
local out = {
string.format("scroll_container[%f,%f;%f,%f;%s;%s;%s;%s]",
x, y,
w, h,
e.__id,
orient or get(e, "orientation"),
fac or get(e, "factor"),
pad or get(e, "padding")
)
}
for i = 1, #e do
local c = e[i]
local cx = resolve_layout_units(get(c, "x"), w)
local cy = resolve_layout_units(get(c, "y"), h)
local cw, ch
if c.w then
cw = resolve_layout_units(get(c, "w"), w)
ch = resolve_layout_units(get(c, "h"), h)
end
out[#out +1] = c:render(cx, cy, cw, ch)
end
out[#out +1] = "scroll_container_end[]"
if e._scrollbar then
e._scrollbar.value = e._scroll_pos or ""
out[#out +1] = e._scrollbar:render()
else
local v = e.__fs._ctx.fields[e.__id]
out[#out +1] = string.format("scrollbar[-800,-800;0,0;%s;%s;%s]", get(e, "orientation"), e.__id, v and v:sub(5) or "")
end
return table.concat(out)
end,
scrollbar = function(e, fn, ...)
if type(fn) == "function" then
e._scrollbar = fn()
else
e._scrollbar = fs_scrollbar(fn, ...)
end
e._scrollbar.__id = e.__id
return e
end,
-- A non-transient name is required in order to preserve scroll position upon a rebuild.
named = function(e, name)
e.__id = name
return e
end
}
fs_scroll_container.__index = fs_scroll_container
setmetatable(fs_scroll_container, {
__call = function(_, x, y, w, h, orientation, factor, padding)
local e = {x = x, y = y, w = w, h = h, orientation = orientation or "vertical", factor = factor or "", padding = padding or "0", _styles = {}, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx}
e.__id = new_id()
setmetatable(e, fs_scroll_container)
table.insert(ctx, e)
ctx = e
return e
end
})
local function fs_scroll_container_end()
if getmetatable(ctx) ~= fs_scroll_container then
minetest.log("warn", "`scroll_container_end` has no scroll container to end; it will be ignored.")
return
end
ctx = ctx.__parent
end
-- MARK: Builtin layouting helpers
local fs_group = {
render = function(e, x, y, w, h)
local out = {}
for i = 1, #e do
local c = e[i]
local cx = resolve_layout_units(get(c, "x"), e.width) +(x or get(e, "x"))
local cy = resolve_layout_units(get(c, "y"), e.height) +(y or get(e, "y"))
local cw, ch
if c.w then
cw = resolve_layout_units(get(c, "w"), e.width)
ch = resolve_layout_units(get(c, "h"), e.height)
end
out[#out +1] = c:render(cx, cy, cw, ch)
end
return table.concat(out)
end,
}
fs_group.__index = fs_group
setmetatable(fs_group, {
__call = function(_, x, y, w, h)
local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx}
setmetatable(e, fs_group)
table.insert(ctx, e)
ctx = e
return e
end
})
local function fs_group_end()
if getmetatable(ctx) ~= fs_group then
minetest.log("warn", "`group_end` has no group to end; it will be ignored.")
return
end
ctx = ctx.__parent
end
local fs_row = {
render = function(e, x, y, w, h)
x = x or get(e, "x")
y = y or get(e, "y")
w = w or get(e, "w")
h = h or get(e, "h")
local axis = e._direction == "column" and y or x
local axis_size = e._direction == "column" and h or w
local out = {}
local total_grow = 0
local used_space = 0
local flex_found
-- Pass 1: Collect total sizing information to allow layout computation.
for i = 1, #e do
local c = e[i]
local ca, ca_flex = resolve_flex_layout_units(get(c, e._direction == "column" and "h" or "w"))
if c.w then
if ca_flex then
flex_found = true
c.__flex = ca
total_grow = total_grow +ca
else
used_space = used_space +(ca or 0)
end
end
end
used_space = used_space +(e._gap *math.max(0, #e -1))
local grow_basis = total_grow > 0 and (math.max(0, axis_size -used_space) /total_grow) or 0
-- If any flex element exists, it will take up all unused space, so the total width is guaranteed to be 100%.
-- Otherwise, we can simply use the size we already calculated for fixed elements.
local total_width = flex_found and axis_size or used_space
-- Pass 2: Assign element positions based on flex ratios.
local current = 0
for i = 1, #e do
local c = e[i]
c.__flex_offset = current
if c.__flex then
c.__flex = c.__flex *grow_basis
current = current +c.__flex +e._gap
else
current = current +resolve_layout_units(get(c, e._direction == "column" and "h" or "w")) +e._gap
end
end
-- Pass 3: Justify and render.
local base = axis
if e._align == "center" then
base = axis +((axis_size -total_width) /2)
elseif e._align == "right" then
base = axis +axis_size -total_width
end
for i = 1, #e do
local c = e[i]
if c.w then
if e._direction == "column" then
out[#out +1] = c:render(resolve_layout_units(get(c, "x")), base +c.__flex_offset, resolve_layout_units(get(c, "w")), c.__flex)
else
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y")), c.__flex, resolve_layout_units(get(c, "h")))
end
else
if e._direction == "column" then
out[#out +1] = c:render(resolve_layout_units(get(c, "x")), base +c.__flex_offset)
else
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y")))
end
end
end
return table.concat(out)
end,
direction = function(e, dir)
e._direction = dir
return e
end,
gap = function(e, gap)
e._gap = gap
return e
end,
align = function(e, align)
e._align = align
return e
end,
}
fs_row.__index = fs_row
setmetatable(fs_row, {
__call = function(_, x, y, w, h)
local e = {x = x, y = y, w = w, h = h, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx}
setmetatable(e, fs_row)
table.insert(ctx, e)
ctx = e
return e
end
})
local function fs_row_end()
if getmetatable(ctx) ~= fs_row then
minetest.log("warn", "`row_end` has no row to end; it will be ignored.")
return
end
ctx = ctx.__parent
end
local function fs_column(...)
return fs_row(...)
:direction "column"
end
local fs_column_end = fs_row_end
-- MARK: Building
local Window = {
render = function(e)
local out = {
"formspec_version[10]",
string.format("size[%f,%f]", e.width, e.height)
}
if e._no_prepend then
out[#out +1] = "no_prepend[]"
end
if e._position then
out[#out +1] = string.format("position[%f,%f]", e._position.x, e._position.y)
end
if e._anchor then
out[#out +1] = string.format("anchor[%f,%f]", e._anchor.x, e._anchor.y)
end
if e._padding then
out[#out +1] = string.format("padding[%f,%f]", e._padding.x, e._padding.y)
end
if e._modal then
out[#out +1] = "allow_close[false]"
end
for i = 1, #e do
local c = e[i]
local cx = resolve_layout_units(get(c, "x"), e.width)
local cy = resolve_layout_units(get(c, "y"), e.height)
local cw, ch
if c.w then
cw = resolve_layout_units(get(c, "w"), e.width)
ch = resolve_layout_units(get(c, "h"), e.height)
end
out[#out +1] = c:render(cx, cy, cw, ch)
end
return table.concat(out)
end,
no_prepend = function(e)
e._no_prepend = true
return e
end,
position = function(e, x, y)
e._position = {x = x, y = y}
return e
end,
anchor = function(e, x, y)
e._anchor = {x = x, y = y}
return e
end,
padding = function(e, x, y)
e._padding = {x = x, y = y}
return e
end,
modal = function(e, modal)
e._modal = modal ~= false and true or false
return e
end,
onclose = function(e, fn)
e._onclose = fn
return e
end
}
Window.__index = Window
local function fs_begin(w, h)
ctx = {_events = {on_click = {}, on_change = {}, on_scrollbar_event = {}, on_enter = {}}, width = w or 12, height = h or 10}
setmetatable(ctx, Window)
return ctx
end
local function fs_end()
local _ctx = ctx
ctx = nil
return _ctx
end
local Context = {
update = function(e)
-- `_inert` should be set when many state updates may take place at once, to avoid a rapid succession of rebuilds.
-- After these updates have taken place, the user should call `rebuild()` manually if `_dirty` is true.
if e._inert then
e._dirty = true
else
e:rebuild()
end
end,
rebuild = function(e)
e._dirty = nil
e:clear_state_bindings()
local tracker = e._linked_states
table.insert(observers, tracker)
local fs = type(e.formspec) == "function" and e.formspec(e.args) or e.formspec
table.remove(observers)
fs._ctx = e
for _, el in ipairs(fs) do
for _, x in pairs(el) do
local mt = getmetatable(x)
if mt == state or mt == DerivedState then
x._getters[e.id] = e
e._linked_states[x] = true
end
end
end
e._events = fs._events
local str = fs:render()
if e._is_inventory then
e._player:set_inventory_formspec(str)
else
minetest.show_formspec(e.target, e.id, str)
end
end,
clear_state_bindings = function(e)
for x in pairs(e._linked_states) do
x._getters[e.id] = nil
end
end,
deinit = function(e)
e:clear_state_bindings()
end
}
Context.__index = Context
local function fs_show(target, fs, args)
local id = "form"..new_id()
local ctx = setmetatable({
formspec = fs,
fields = {},
target = type(target) == "string" and target or target:get_player_name(),
id = id,
_linked_states = {},
args = args or {}
}, Context)
contexts[id] = ctx
ctx:rebuild()
return ctx
end
local function fs_set_inventory(p, fs, args)
local id = "form"..new_id()
local name = type(p) == "string" and p or p:get_player_name()
local ctx = setmetatable({
_is_inventory = true,
_player = type(p) == "string" and minetest.get_player_by_name(p) or p,
formspec = fs,
fields = {},
target = name,
id = id,
_linked_states = {},
args = args or {}
}, Context)
inventories[name] = ctx
ctx:rebuild()
return ctx
end
local function fs_remove_inventory(p)
local name = p:get_player_name()
local inv = inventories[name]
if inv then
inv:deinit()
inventories[name] = nil
end
end
-- MARK: Callback handling
minetest.register_on_player_receive_fields(function(p, form, fields)
local name = p:get_player_name()
local ctx = contexts[form]
-- If this is an event from a player inventory, check if we have a context managing that inventory.
-- No other special handling is needed, because the context already knows that it's an inventory and will react appropriately.
if form == "" then
ctx = inventories[name]
end
-- Ensure that a) players can only trigger effects of formspecs that were opened legitimately, and b) only the player who opened it may interact with a given formspec instance.
if ctx and ctx.target == name then
ctx._inert = true
if fields.key_enter_field and ctx._events.on_enter[fields.key_enter_field] then
ctx._events.on_enter[fields.key_enter_field](fields[fields.key_enter_field])
end
for k, v in pairs(fields) do
if ctx._events.on_click[k] then
ctx._events.on_click[k](v)
elseif ctx._events.on_scrollbar_event[k] then
local ev = minetest.explode_scrollbar_event(v)
ctx._events.on_scrollbar_event[k](ev.type, ev.value)
elseif (not ctx.fields[k] or ctx.fields[k] ~= v) and ctx._events.on_change[k] then
ctx._events.on_change[k](v)
end
end
ctx.fields = fields
ctx._inert = nil
if fields.quit then
ctx:deinit()
contexts[form] = nil
else
if ctx._dirty then
ctx:rebuild()
ctx._dirty = nil
end
end
end
end)
-- Remove all contexts tied to a leaving player.
minetest.register_on_leaveplayer(function(p)
fs_remove_inventory(p)
end)
-- MARK: API exposure
imfs = {
_contexts = contexts,
_inventories = inventories,
state = state,
derive = derive,
get_field = get,
begin = fs_begin,
end_ = fs_end,
show = fs_show,
set_inventory = fs_set_inventory,
remove_inventory = fs_remove_inventory,
resolve_layout_units = resolve_layout_units,
resolve_flex_layout_units = resolve_flex_layout_units,
container_start = function()
local container = {__parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx}
table.insert(ctx, container)
ctx = container
end,
container_end = function()
ctx = ctx.__parent
end,
add_to_context = function(container)
table.insert(ctx, container)
end,
theme = theme,
style = fs_style,
tooltip = fs_tooltip,
_named_tooltip = fs_named_tooltip,
label = fs_label,
arealabel = fs_arealabel,
box = fs_box,
image = fs_image,
item_image = fs_item_image,
model = fs_model,
button = fs_button,
list = fs_list,
inventory = fs_inventory,
listring = fs_listring,
field = fs_field,
textarea = fs_textarea,
checkbox = fs_checkbox,
scrollbar = fs_scrollbar,
scroll_container = fs_scroll_container,
scroll_container_end = fs_scroll_container_end,
group = fs_group,
group_end = fs_group_end,
row = fs_row,
row_end = fs_row_end,
column = fs_column,
column_end = fs_column_end,
-- Can be called in a game's base mod to globalize imfs for brevity.
export = function()
for k, v in pairs(imfs) do
local key
if k == "state" or k == "derive" then
key = k
elseif k == "end_" then
key = "fs_end"
else
key = "fs_"..k
end
_G[key] = v
end
end
}