Fix a lot of broken things.

This commit is contained in:
Signal 2026-02-02 13:58:57 -05:00
parent 6e0f653928
commit be73993547
2 changed files with 309 additions and 95 deletions

385
init.lua
View file

@ -18,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
@ -35,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
@ -63,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
@ -90,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,
@ -207,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"))
@ -285,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
@ -335,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)
@ -360,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,
}
@ -453,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"))
@ -608,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)
@ -627,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
@ -660,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
})
@ -684,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
@ -698,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]
@ -708,14 +751,38 @@ local fs_scroll_container = {
return table.concat(out)
end,
scrollbar = function(e, 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
e._scrollbar = fn()
fn()
else
e._scrollbar = fs_scrollbar(fn, ...)
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
@ -725,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)
@ -747,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)
@ -804,14 +894,16 @@ 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 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)
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
c.__flex = ca
total_grow = total_grow +ca
else
used_space = used_space +(ca or 0)
end
end
end
end
@ -827,12 +919,14 @@ local fs_row = {
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
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"), axis_size) +e._gap
end
end
end
@ -846,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
@ -932,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
@ -973,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
@ -980,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
@ -1001,6 +1137,7 @@ local Context = {
end,
rebuild = function(e)
e._dirty = nil
e._ignore = true
e:clear_state_bindings()
local tracker = e._linked_states
@ -1012,6 +1149,12 @@ local Context = {
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
@ -1027,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)
@ -1046,6 +1191,8 @@ local Context = {
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.
@ -1057,7 +1204,7 @@ local Context = {
Context.__index = Context
local function fs_show(target, fs, state)
local id = "form"..new_id()
local id = "form"..unique_id()
local ctx = setmetatable({
formspec = fs,
fields = {},
@ -1080,7 +1227,7 @@ local function fs_show(target, fs, state)
end
local function fs_set_inventory(p, fs, state)
local id = "form"..new_id()
local id = "form"..unique_id()
local name = type(p) == "string" and p or p:get_player_name()
local ctx = setmetatable({
_is_inventory = true,
@ -1111,53 +1258,102 @@ 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]
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
-- 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()
else
if ctx._dirty then
ctx:rebuild()
ctx._dirty = nil
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
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
-- Remove all contexts tied to a leaving player.
minetest.register_on_leaveplayer(function(p)
fs_remove_inventory(p)
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]
-- 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
handler(ctx, fields)
end
end)
-- Remove all contexts tied to a leaving player.
minetest.register_on_leaveplayer(function(p)
fs_remove_inventory(p)
end)
end
-- MARK: API exposure
@ -1170,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,
@ -1194,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,