602 lines
16 KiB
Lua
602 lines
16 KiB
Lua
local ns = {
|
|
contexts = {}
|
|
}
|
|
|
|
local fe = minetest.formspec_escape
|
|
local hte = minetest.hypertext_escape
|
|
|
|
local ctx
|
|
|
|
local observers = {}
|
|
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)
|
|
end
|
|
return e._val
|
|
end,
|
|
set = function(e, val)
|
|
if val == nil then error "!" end
|
|
e._oldVal = 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
|
|
|
|
function state(val)
|
|
local state = {_getters = {}, _val = val}
|
|
setmetatable(state, State)
|
|
|
|
return state
|
|
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_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,
|
|
}
|
|
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
|
|
}
|
|
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_image = {
|
|
render = function(e)
|
|
return string.format("label[%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
|
|
}
|
|
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_button = {
|
|
render = function(e)
|
|
local out = {}
|
|
for _, x in pairs(e._styles) do
|
|
out[#out +1] = x:render()
|
|
end
|
|
out[#out +1] = string.format("button[%f,%f;%f,%f;%s;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), e.__id, get(e, "label"))
|
|
return table.concat(out)
|
|
end,
|
|
style = function(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] = fs_style(e.__id..":"..state, props, true)
|
|
return e
|
|
end,
|
|
onclick = function(e, fn)
|
|
ctx._events.on_click[e.__id] = fn
|
|
return e
|
|
end
|
|
}
|
|
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 = "_"..minetest.get_us_time().."_"..math.random(1, 100000)
|
|
setmetatable(e, fs_button)
|
|
table.insert(ctx, e)
|
|
return e
|
|
end
|
|
})
|
|
|
|
local fs_field = {
|
|
render = function(e)
|
|
local out = {}
|
|
for _, x in pairs(e._styles) do
|
|
out[#out +1] = x:render()
|
|
end
|
|
out[#out +1] = string.format("%s[%f,%f;%f,%f;%s;%s;%s]", e._textarea and "textarea" or "field", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "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
|
|
|
|
return table.concat(out)
|
|
end,
|
|
style = function(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] = fs_style(e.__id..":"..state, props, true)
|
|
return e
|
|
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
|
|
}
|
|
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 = "_"..minetest.get_us_time().."_"..math.random(1, 100000)
|
|
setmetatable(e, fs_field)
|
|
table.insert(ctx, e)
|
|
return e
|
|
end
|
|
})
|
|
|
|
function fs_textarea(...)
|
|
return fs_field(...)
|
|
:multiline()
|
|
end
|
|
|
|
local fs_scrollbar = {
|
|
render = function(e)
|
|
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]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), get(e, "orientation"), e.__id, get(e, "value"))
|
|
|
|
return table.concat(out)
|
|
end,
|
|
style = function(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] = fs_style(e.__id..":"..state, props, true)
|
|
return e
|
|
end,
|
|
options = function(e, props)
|
|
return e
|
|
end,
|
|
onchange = function(e, fn)
|
|
ctx._events.on_scrollbar_event[e.__id] = fn
|
|
return e
|
|
end,
|
|
}
|
|
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)
|
|
local out = {string.format("scroll_container[%f,%f;%f,%f;%s;%s;%s;%s]", get(e, "x"), get(e, "y"), get(e, "w"), get(e, "h"), e.__id, get(e, "orientation"), get(e, "factor"), get(e, "padding"))}
|
|
for _, x in ipairs(e) do
|
|
out[#out +1] = x:render()
|
|
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, ...)
|
|
e._scrollbar = fs_scrollbar(..., e.__id, e._scroll_pos or "")
|
|
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
|
|
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 = "_"..minetest.get_us_time().."_"..math.random(1, 100000)
|
|
setmetatable(e, fs_scroll_container)
|
|
table.insert(ctx, e)
|
|
ctx = e
|
|
return e
|
|
end
|
|
})
|
|
|
|
function fs_scroll_container_end()
|
|
ctx = ctx.__parent
|
|
end
|
|
|
|
|
|
-- MARK: Building
|
|
|
|
local Fs = {
|
|
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 _, x in ipairs(e) do
|
|
out[#out +1] = x:render()
|
|
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)
|
|
e._modal = true
|
|
return e
|
|
end,
|
|
onclose = function(e, fn)
|
|
e._onclose = fn
|
|
return e
|
|
end
|
|
}
|
|
Fs.__index = Fs
|
|
|
|
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, Fs)
|
|
return ctx
|
|
end
|
|
|
|
function fs_end()
|
|
local _ctx = ctx
|
|
ctx = nil
|
|
return _ctx
|
|
end
|
|
|
|
local Context = {
|
|
update = function(e)
|
|
if e._inert then
|
|
e._dirty = true
|
|
else
|
|
e:rebuild()
|
|
end
|
|
end,
|
|
rebuild = function(e)
|
|
e:clear_state_bindings()
|
|
|
|
local tracker = e._linked_states
|
|
table.insert(observers, tracker)
|
|
|
|
local fs = type(e.formspec) == "function" and e.formspec() 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()
|
|
minetest.show_formspec(e.target, e.id, str)
|
|
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
|
|
|
|
function fs_show(target, fs)
|
|
local id = "form_"..minetest.get_us_time().."_"..math.random(1, 100000)
|
|
local ctx = setmetatable({
|
|
formspec = fs,
|
|
fields = {},
|
|
data = {},
|
|
target = target,
|
|
id = id,
|
|
_linked_states = {},
|
|
}, Context)
|
|
|
|
ns.contexts[id] = ctx
|
|
|
|
ctx:rebuild()
|
|
end
|
|
|
|
minetest.register_on_player_receive_fields(function(p, form, fields)
|
|
local ctx = ns.contexts[form]
|
|
if ctx 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 ctx.fields[k] and 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()
|
|
ns.contexts[form] = nil
|
|
else
|
|
if ctx._dirty then
|
|
ctx:rebuild()
|
|
ctx._dirty = nil
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
local counter = state(0)
|
|
local str = state("")
|
|
local pressed = state(false)
|
|
|
|
local function fs()
|
|
fs_begin(12, 10)
|
|
|
|
fs_label(1, 2, derive(function() return "Counter: "..counter() end))
|
|
fs_label(1, 3, derive(function() return "Length: "..#str end))
|
|
|
|
if pressed() then
|
|
fs_label(1, 4, "Pressed!!")
|
|
end
|
|
|
|
fs_button(4, 0.5, 4, 0.75, "Increment")
|
|
:style {
|
|
bgcolor = "#aaf"
|
|
}
|
|
:style("hovered", {
|
|
bgcolor = "#faa"
|
|
})
|
|
:style("pressed", {
|
|
bgcolor = "#afa"
|
|
})
|
|
:onclick(function()
|
|
counter(counter() +1)
|
|
end)
|
|
|
|
fs_field(4, 2, 4, 0.75, "Test", str)
|
|
:onchange(function(value)
|
|
str(value)
|
|
end)
|
|
:onenter(function()
|
|
pressed(true)
|
|
end)
|
|
|
|
fs_scroll_container(4, 3, 8, 7)
|
|
:named("test")
|
|
|
|
for i = 0, 10 do
|
|
fs_label(0, i, i)
|
|
end
|
|
|
|
fs_scroll_container_end()
|
|
|
|
return fs_end()
|
|
end
|
|
|
|
minetest.register_chatcommand("demo", {
|
|
func = function(name)
|
|
fs_show(name, fs)
|
|
end
|
|
})
|