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 })