Compare commits

...

2 commits
v1.1 ... master

2 changed files with 340 additions and 103 deletions

View file

@ -19,6 +19,8 @@ Builder functions are used by either `imfs.show` (to show an interface to a play
You can pass a table as the last argument to an entry point like `imfs.show` to hold state external to the builder function; it will then be passed as an argument to the builder function during every rebuild. This way, you can declare the builder function itself in one place and use it in another without having to rely entirely on closure capture or global variables.
Builder functions may also take a second argument, which is a function that will close the current context.
Imfs does not strictly require using a builder function: if an interface is purely static and need never change, you can use a prebuilt element tree instead of a builder function, which avoids rebuilding the tree every time the interface is shown.
### Elements
@ -93,6 +95,12 @@ If you want to use the state API for a system of your own, remember that:
* When set, a state notifies each entry in `_getters` by calling that object's `:update()` method.
* To collect state dependencies from a given function, 1) create a tracking table and `table.insert(imfs.state.observers, tracker)`, 2) call the function, and 3) `table.remove(imfs.state.observers)`. The tracking table will then be filled with all states that were accessed between the `table.insert` and `table.remove`.
### IDs
One of the biggest problems with the immediate-mode approach is that it is difficult to determine when a particular element has changed, since a given build run is not guaranteed to match the output of the previous run in any way, and if element IDs were unique, it would be nigh-impossible to determine what an element's previous value was without shifting to a more retained-mode approach. To work around this, the internal IDs of elements are assigned somewhat deterministically based on the order of their creation. Thus, if a given builder always produces the same sequence of element creations in the same order, each element will always be assigned the same ID on every run. This heuristic, of course, breaks down when a builder function does a lot of conditional or iterative rendering, because the size and ordering of the element tree can change significantly across rebuilds. To solve this, imfs provides `imfs.scope()`. This will create a named sub-scope, causing elements created within it to be identified relative to the beginning of the sub-scope, rather than the global scope. Thus, if an element tree within a scope doesn't change layout, the elements will have stable IDs that allow `onchange` and the like to work properly.
With that said, it is generally not important for you to use scopes in your UI, because most elements don't depend on knowledge of their previous state. The exceptions include fields and scroll containers. Fields need to know their old value in order to determine whether to fire an `onchange` event, otherwise they would be compelled to call `onchange` every time some element triggers an action (even if that action did not affect state). Unmanaged scroll containers need to know their previous state so that they can preserve their scroll position across rebuilds despite having no external state object with which to track it. (Note also that these elements still need not be enclosed in a scope if the element tree preceding them is constant.)
# Example code
Create a mod with this code and run `/demo` to see the example in action.
@ -178,7 +186,7 @@ minetest.register_chatcommand("demo", {
### `imfs.export()`
Export the imfs API to \_G, with "fs_" prefixes. This is mainly intended for custom, from-scratch games to make life a bit easier.
Export the imfs API to `_G`, with "fs_" prefixes. This is mainly intended for custom, from-scratch games to make life a bit easier.
### `imfs.begin(window_width, window_height)`
@ -189,22 +197,33 @@ Begin a root imfs container. This returns a `Window` object with the following m
* `Window:padding(x, y)`: Set the padding of the formspec window. See `padding[]` in the [Minetest API documentation](https://api.luanti.org/formspec/).
* `Window:modal([modal])`: Make this window modal (meaning that it cannot be directly closed by the user with Esc). If `modal` is false, there will be no effect (this can be useful for making modality state-dependent).
* `Window:onclose(fn)`: Registers `fn` to be called when the window closes. (This may be triggered when the target player leaves the game.)
* `Window:bgcolor(foreground, fullscreen)`: Set the window's background color. If `fullscreen` is absent or false, `foreground` will be applied as the window's foreground color. If `fullscreen` is `true`, `foreground` will be applied as the window's background color. If `fullscreen` is a string, `foreground` will be the window's foreground color and `fullscreen` will be the window's background color.
### `imfs.end_()`
Ends the current window, and returns it. Note: The name ends with an underscore so as not to conflict with the Lua keyword (I couldn't think of a better word for ending the window than 'end').
### `imfs.scope(name)`
Begins a scope with the given name. All subsequent elements will be identified with indices relative to the beginning of the scope.
### `imfs.scope_end()`
Exits the last entered scope. Subsequent elements will resume identification relative to the parent scope.
### `imfs.show(player, builder, state)`
Show the interface defined by the provided builder function to the given player. The generated context will remain active until the player closes the formspec or leaves the game.
This returns a `Context` object, which is the the top-level dependent for all states used in the interface. Unlike in some other libraries, you don't really store state on the `Context` object (the builder function can't access it directly). Instead, you should store state as local variables in the function from which you call `show` (or somewhere else if they should be global) along with the builder function; that way, the builder function can capture the state specific to the action and the player that invoked the interface and only that. You can alternatively declare the builder function externally, then pass the new state objects in the `args` table, as it will be passed on to the builder function and will persist across rebuilds. This is arguably the better option since it avoids re-allocating the builder function every time it's first shown.
This returns a `Context` object, which is the the top-level dependent for all states used in the interface. Unlike in some other libraries, you don't really store state on the `Context` object (the builder function can't access it directly). Instead, you should store state as local variables in the function from which you call `show` (or somewhere else if they should be global) along with the builder function; that way, the builder function can capture the state specific to the action and the player that invoked the interface and only that. You can alternatively declare the builder function externally, then pass the new state objects in the `state` table, as it will be passed on to the builder function and will persist across rebuilds. This is arguably the better option since it avoids re-allocating the builder function every time it's first shown.
If `builder` is a function, it will be called with two arguments: 1) the `state` table, and 2) a function that will manually close the context. (Externally, you can call `:close()` on the context to close it manually.)
You can pass a static imfs tree instead of a builder if you so desire.
### `imfs.set_inventory(player, builder, state)`
Set `player`'s inventory to the interface defined by `builder`. This context will persist indefinitely until it is manually removed or the player leaves the game. Otherwise, this behaves in the same way as `imfs.show`.
Set `player`'s inventory to the interface defined by `builder`. This context will persist indefinitely until it is manually removed or the player leaves the game. Otherwise, this behaves in the same way as `imfs.show`, with the exception that if `builder` is a function, it will not receive a `close` function as an argument (since inventories cannot be closed, only replaced).
### `imfs.clear_inventory(player)`
@ -227,7 +246,7 @@ End the current container and revert to its parent. This can be used to create c
Note that all elements have a `:render()` method that outputs their formspec representation, and accepts overrides for x, y, width, and height as extra arguments. This is used for creating custom layouting containers which interpret position and size in a non-absolute way.
Many sized elements have a `:tooltip(text[, background_color[, text_color]])` method that creates a tooltip for the element. Note that for elements without a `name` attribute in the formspec format, this will be an area tooltip and will not be occluded by any overlaying elements. Since imfs doesn't track which elements occlude which, it is up to you to not add area tooltips to occluded elements.
Many sized elements have a `:tooltip(text, enabled[, background_color[, text_color]])` method that creates a tooltip for the element. Note that for elements without a `name` attribute in the formspec format, this will be an area tooltip and will not be occluded by any overlaying elements. Since imfs doesn't track which elements occlude which, it is up to you to not add area tooltips to occluded elements. An easy way to do this is to pass a boolean as the second argument: if `false`, the tooltip will not be added. If the second argument is not a boolean, it will be interpreted as `background_color` (and `background_color` as `text_color`).
### `imfs.label(x, y, text)`

370
init.lua
View file

@ -4,6 +4,7 @@ local hte = minetest.hypertext_escape
local ctx
local player_contexts = {}
local contexts = {}
local inventories = {}
@ -17,11 +18,14 @@ local function resolve_layout_units(value, ref)
local num = tonumber(value)
if num then return num end
local percent = value:match "(%d+)%%"
local percent = value:match "(%d*.?%d+)%%"
if ref and percent then
return tonumber(percent) /100 *ref
local sign, offset = value:match "%%%s*([+-])%s*(%d*.?%d+)"
return tonumber(percent) /100 *ref +(tonumber(offset or 0) *(sign == "-" and -1 or 1))
end
print(string.match("100%", "(%d*.?%d+)%%"), percent, ref)
error("Malformed layout units: "..value)
else
return value
@ -34,12 +38,13 @@ local function resolve_flex_layout_units(value, ref)
local num = tonumber(value)
if num then return num end
local percent = value:match "(%d+)%%"
local percent = value:match "(%d*.?%d+)%%"
if ref and percent then
return tonumber(percent) /100 *ref
local sign, offset = value:match "%%%s*([+-])%s*(%d*.?%d+)"
return tonumber(percent) /100 *ref +(tonumber(offset or 0) *(sign == "-" and -1 or 1))
end
local flex = value:match "(%d+)x"
local flex = value:match "(%d*.?%d+)x"
if flex then
return tonumber(flex), "flex"
end
@ -62,18 +67,49 @@ local function add_elem_style(e, state, props)
end
-- This should be a method on any element classes that support tooltips.
local function add_elem_tooltip(e, text, bgcolor, txtcolor)
local function add_elem_tooltip(e, text, enabled, bgcolor, txtcolor)
-- Permit the user to pass a boolean to disable the tooltip (useful for modal dialogs that should occlude tooltips without more bothersome composition boilerplate than necessary).
if enabled == false then
return
elseif enabled ~= true then
txtcolor = bgcolor
bgcolor = enabled
end
-- 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)
-- We must overload `render()` in order to ensure the tooltip's position matches the parent's exactly in all cases.
local render = e.render
e.render = function(e, x, y, w, h)
local out = render(e, x, y, w, h)
return table.concat{out, imfs.tooltip.init(x or e.x, y or e.y, w or e.w, h or e.h, text, bgcolor, txtcolor, true):render()}
end
end
return e
end
local name_scope = {"_"}
local nth_id = {0}
local function scope(name)
table.insert(name_scope, name)
table.insert(nth_id, 0)
end
local function scope_end()
table.remove(name_scope)
table.remove(nth_id)
end
local function new_id()
local depth = #nth_id
nth_id[depth] = nth_id[depth] +1
return table.concat(name_scope, ".").."_"..nth_id[depth]
end
local function unique_id()
return "_"..minetest.get_us_time().."_"..math.random(1, 100000)
end
@ -89,7 +125,7 @@ local state = {
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)
observer[e] = true
end
return e._val
end,
@ -206,6 +242,12 @@ setmetatable(fs_style, {
})
local fs_tooltip = {
_no_layout = true,
init = function(x, y, w, h, text, bgcolor, txtcolor)
local e = {x = x, y = y, w = w, h = h, text = text, bgcolor = bgcolor or imfs.theme.tooltip_bg or "#444", txtcolor = txtcolor or imfs.theme.tooltip_text or "#aaa"}
setmetatable(e, imfs.tooltip)
return e
end,
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"))
@ -284,7 +326,7 @@ setmetatable(fs_arealabel, {
local fs_hypertext = {
render = function(e, x, y, w, h)
return string.format("hypertext[%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"), hte(get(e, "txt")))
return string.format("hypertext[%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"), e.__id, get(e, "txt"))
end,
onaction = function(e, fn)
ctx._events.on_click[e.__id] = fn
@ -334,7 +376,7 @@ local fs_image = {
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"))
return string.format("image[%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, "texture"), get(e, "middle"))
end
end,
animated = function(e, frames, duration, start)
@ -359,7 +401,7 @@ setmetatable(fs_image, {
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"))
return string.format("item_image[%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, "item"))
end,
tooltip = add_elem_tooltip,
}
@ -452,9 +494,9 @@ local fs_button = {
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"))
out[#out +1] = string.format("image_button[%f,%f;%f,%f;%s;%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"))
out[#out +1] = string.format("image_button%s[%f,%f;%f,%f;%s;%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"))
@ -607,7 +649,7 @@ local fs_field = {
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 = {}}
local e = {x = x, y = y, w = w, h = h, label = value and label or "", value = value or label or "", _styles = {}}
e.__id = new_id()
setmetatable(e, fs_field)
table.insert(ctx, e)
@ -626,7 +668,7 @@ local fs_scrollbar = {
if e._options then
out[#out +1] = "scrollbaroptions["
local first = true
for k, v in pairs(options) do
for k, v in pairs(e._options) do
if not first then
out[#out +1] = ";"
end
@ -659,7 +701,7 @@ setmetatable(fs_scrollbar, {
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)
ctx[#ctx +1] = e
return e
end
})
@ -683,8 +725,11 @@ local fs_scroll_container = {
}
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 cx, cy
if c.x then
cx = resolve_layout_units(get_raw(c, "x"), w)
cy = resolve_layout_units(get_raw(c, "y"), h)
end
local cw, ch
if c.w then
@ -697,7 +742,6 @@ local fs_scroll_container = {
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]
@ -707,14 +751,38 @@ local fs_scroll_container = {
return table.concat(out)
end,
scrollbar = function(e, fn, ...)
if type(fn) == "function" then
e._scrollbar = fn()
else
e._scrollbar = fs_scrollbar(fn, ...)
-- Make a fake ctx to put the scrollbar in.
ctx = setmetatable({
__parent = ctx,
_events = setmetatable({}, {
__index = ctx._events,
__newindex = ctx._events
}),
}, {
-- Assignment trap to ensure that the scrollbar gets our ID immediately on construction.
__newindex = function(tbl, key, value)
value.__id = e.__id
return rawset(tbl, key, value)
end
})
if type(fn) == "function" then
fn()
else
fs_scrollbar(fn, ...)
end
e._scrollbar = ctx[1]
ctx = ctx.__parent
e._scrollbar.__id = e.__id
return e
end,
onscroll = function(e, fn)
if e._scrollbar then
e._scrollbar:onchange(fn)
return e
end
e._scrollbar = fs_scrollbar(-800, -800, 0, 0, e._orientation, e.__id):onchange(fn)
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
@ -724,7 +792,22 @@ local fs_scroll_container = {
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}
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)
@ -746,16 +829,24 @@ end
local fs_group = {
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 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 cx, cy
if c.x then
cx = resolve_layout_units(get_raw(c, "x"), w) +x
cy = resolve_layout_units(get_raw(c, "y"), h) +y
end
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)
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)
@ -803,7 +894,8 @@ local fs_row = {
-- 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 not c._no_layout then
local ca, ca_flex = resolve_flex_layout_units(get(c, e._direction == "column" and "h" or "w"), axis_size)
if c.w then
if ca_flex then
flex_found = true
@ -814,6 +906,7 @@ local fs_row = {
end
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
@ -826,12 +919,14 @@ local fs_row = {
local current = 0
for i = 1, #e do
local c = e[i]
if not c._no_layout then
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
current = current +resolve_layout_units(get(c, e._direction == "column" and "h" or "w"), axis_size) +e._gap
end
end
end
@ -845,17 +940,31 @@ local fs_row = {
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")))
if c._no_layout then
local cx, cy
if c.x then
cx = resolve_layout_units(get_raw(c, "x"), w) +x
cy = resolve_layout_units(get_raw(c, "y"), h) +y
end
else
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)
elseif c.w then
if e._direction == "column" then
out[#out +1] = c:render(resolve_layout_units(get(c, "x")), base +c.__flex_offset)
out[#out +1] = c:render(resolve_layout_units(get(c, "x"), w) +x, base +c.__flex_offset, resolve_layout_units(get(c, "w"), w), c.__flex)
else
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y")))
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"), h) +y, c.__flex, resolve_layout_units(get(c, "h"), h))
end
elseif c.x then
if e._direction == "column" then
out[#out +1] = c:render(resolve_layout_units(get(c, "x"), w) +x, base +c.__flex_offset)
else
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"), h) +y)
end
end
end
@ -931,6 +1040,10 @@ local Window = {
out[#out +1] = "allow_close[false]"
end
if e._fullscreen then
out[#out +1] = string.format("bgcolor[%s;%s;%s]", get(e, "_fgcolor"), get(e, "_fullscreen"), get(e, "_bgcolor"))
end
for i = 1, #e do
local c = e[i]
local cx, cy
@ -972,6 +1085,24 @@ local Window = {
onclose = function(e, fn)
e._onclose = fn
return e
end,
bgcolor = function(e, foreground, fullscreen)
if fullscreen then
if fullscreen == true then
e._fullscreen = "true"
e._bgcolor = foreground
else
e._fullscreen = "both"
e._fgcolor = foreground
e._bgcolor = fullscreen
end
elseif not foreground then
e._fullscreen = "neither"
else
e._fullscreen = "false"
e._fgcolor = foreground
end
return e
end
}
Window.__index = Window
@ -979,17 +1110,23 @@ 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)
name_scope = {"_"}
nth_id = {0}
return ctx
end
local function fs_end()
local _ctx = ctx
ctx = nil
name_scope = nil
nth_id = nil
return _ctx
end
local Context = {
update = function(e)
-- This is used to prevent changes in state from inside the builder to trigger another rebuild.
if e._ignore then return end
-- `_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
@ -1000,15 +1137,24 @@ local Context = {
end,
rebuild = function(e)
e._dirty = nil
e._ignore = true
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
local fs = type(e.formspec) == "function" and e.formspec(e.state, function() e:close() end) or e.formspec
e._window = fs
table.remove(observers)
e._ignore = nil
for x in pairs(e._linked_states) do
x._getters[e.id] = e
end
fs._ctx = e
for _, el in ipairs(fs) do
@ -1024,7 +1170,9 @@ local Context = {
e._events = fs._events
local str = fs:render()
if e._is_inventory then
if e._mainmenu then
minetest.update_formspec(str)
elseif e._is_inventory then
e._player:set_inventory_formspec(str)
else
minetest.show_formspec(e.target, e.id, str)
@ -1036,31 +1184,50 @@ local Context = {
end
end,
deinit = function(e)
if contexts[e.id] then
contexts[e.id] = nil
end
if e._window._onclose then
e._window:_onclose()
end
e:clear_state_bindings()
-- Kill our rebuild capability in case any callbacks fire on the way out.
e.rebuild = function() end
end,
close = function(e)
-- Inventories cannot be 'closed', only replaced.
if e._is_inventory then return end
minetest.close_formspec(e.target, e.id)
e:deinit()
end
}
Context.__index = Context
local function fs_show(target, fs, args)
local id = "form"..new_id()
local function fs_show(target, fs, state)
local id = "form"..unique_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 {}
state = state or {}
}, Context)
if player_contexts[ctx.target] then
player_contexts[ctx.target]:deinit()
end
contexts[id] = ctx
player_contexts[ctx.target] = ctx
ctx:rebuild()
return ctx
end
local function fs_set_inventory(p, fs, args)
local id = "form"..new_id()
local function fs_set_inventory(p, fs, state)
local id = "form"..unique_id()
local name = type(p) == "string" and p or p:get_player_name()
local ctx = setmetatable({
_is_inventory = true,
@ -1070,7 +1237,7 @@ local function fs_set_inventory(p, fs, args)
target = name,
id = id,
_linked_states = {},
args = args or {}
state = state or {}
}, Context)
inventories[name] = ctx
@ -1091,6 +1258,81 @@ end
-- MARK: Callback handling
local function handler(ctx, fields)
ctx._inert = true
-- We split event handling into two passes here in order to guarantee that action events take precedence over state change events.
-- Otherwise, it would be possible for a field's state synchronization to accidentally overwrite state changed by an action, which is almost certainly not what the user intended to happen.
-- First pass: Stateful element updates.
for k, v in pairs(fields) do
if 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
-- Handle pressing Enter in a field. (This is somewhere between a state event and an action, so we process between passes.)
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
-- Second pass: Actions.
for k, v in pairs(fields) do
if ctx._events.on_click[k] then
ctx._events.on_click[k](v)
end
end
ctx.fields = fields
ctx._inert = nil
if fields.quit then
ctx:deinit()
else
if ctx._dirty then
ctx:rebuild()
ctx._dirty = nil
end
end
end
-- Compatibility with main menu usage.
if minetest.update_formspec then
local mainmenu_context
function fs_show(fs, state)
if mainmenu_context then
mainmenu_context:deinit()
end
local id = "form"..unique_id()
local ctx = setmetatable({
_mainmenu = true,
formspec = fs,
fields = {},
target = "menu",
id = id,
_linked_states = {},
state = state or {}
}, Context)
mainmenu_context = ctx
ctx:rebuild()
return ctx
end
fs_set_inventory = nil
minetest.button_handler = function(fields)
handler(mainmenu_context, fields)
end
-- Normal in-game usage.
else
minetest.register_on_player_receive_fields(function(p, form, fields)
local name = p:get_player_name()
local ctx = contexts[form]
@ -1102,36 +1344,7 @@ minetest.register_on_player_receive_fields(function(p, form, fields)
-- 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
handler(ctx, fields)
end
end)
@ -1139,6 +1352,8 @@ end)
minetest.register_on_leaveplayer(function(p)
fs_remove_inventory(p)
end)
end
-- MARK: API exposure
@ -1151,6 +1366,8 @@ imfs = {
get_field = get,
begin = fs_begin,
end_ = fs_end,
scope = scope,
scope_end = scope_end,
show = fs_show,
set_inventory = fs_set_inventory,
remove_inventory = fs_remove_inventory,
@ -1175,6 +1392,7 @@ imfs = {
_named_tooltip = fs_named_tooltip,
label = fs_label,
arealabel = fs_arealabel,
hypertext = fs_hypertext,
box = fs_box,
image = fs_image,
item_image = fs_item_image,