From 1754e50d7a7d66336cce2650aa0706b416e7631c Mon Sep 17 00:00:00 2001 From: Signal Date: Sat, 10 Jan 2026 02:43:00 +0000 Subject: [PATCH] Initial commit. --- init.lua | 602 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 init.lua diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..2ef7414 --- /dev/null +++ b/init.lua @@ -0,0 +1,602 @@ +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 +})