Fix a few bugs and add automatic sizing for layout containers.

This commit is contained in:
Signal 2026-03-07 16:30:53 -05:00
parent be73993547
commit 969030a026
2 changed files with 153 additions and 48 deletions

View file

@ -71,6 +71,10 @@ Besides these basic containers, however, imfs also provides several layouting co
* `imfs.row`: A flex row. This automatically positions elements within it based on the provided gap and alignment. Child elements may specify their width as a grow ratio like `"1x"` to dynamically resize to fill any empty space in the row. Higher grow ratios will cause the element to take up a larger portion of the available space compared to other dynamically sized elements.
* `imfs.column`: The same as `imfs.row`, but in the vertical direction.
Inside all containers (including the window), it is possible to specify properties of an element in terms of its parent's size, rather than with absolute formspec units. This is done by passing a percent string (like `"100%"`) instead of a number to the element's constructor. Percentage units may also optionally include a fixed offset to be applied to the computed percentage by following the percent with `+` or `-` and a number (like `"100% - 0.5"`).
Groups and flex containers may specify their width or height as `"auto"` to automatically compute their width or height based on the accumulated size of their children (note that for flex containers this only works on the secondary axis). Using percentage units relative to such a computed dimension, while technically possible, will likely cause problems, as doing so results in a circular dpendency (which is broken internally by having the container interpret relative sizes as zero for the purposes of `"auto"`).
To create a custom layouting container, the procedure is essentially as follows:
1. Create a Lua class for the container.
2. In the class constructor, call `imfs.container_start(container)` with the newly created container instance.

197
init.lua
View file

@ -62,7 +62,26 @@ local function add_elem_style(e, state, props)
props = state
state = "default"
end
e._styles[state] = imfs.style(e.__id..":"..state, props, true)
-- Permit targeting pseudo-elements as well.
if state:sub(1, 1) == "." then
e._styles[state] = imfs.style(e.__id..state, props, true)
else
e._styles[state] = imfs.style(e.__id..":"..state, props, true)
end
return e
end
-- Bulk version of `add_elem_style`.
local function add_elem_styles(e, states)
for state, props in pairs(states) do
if state:sub(1, 1) == "." then
e._styles[state] = imfs.style(e.__id..state, props, true)
else
e._styles[state] = imfs.style(e.__id..":"..state, props, true)
end
end
return e
end
@ -75,16 +94,22 @@ local function add_elem_tooltip(e, text, enabled, bgcolor, txtcolor)
txtcolor = bgcolor
bgcolor = enabled
end
-- We must overload `render()` both to ensure that tooltips don't clutter the element tree and mess with layout containers
-- and (for area tooltips) in order to ensure the tooltip's position and size match the parent's exactly in all cases.
-- 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
-- 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()}
return out..imfs.tooltip.named(e.__id, text, bgcolor, txtcolor):render()
end
-- For other elements, if they have a size, use an area tooltip instead.
elseif e.w then
local render = e.render
e.render = function(e, x, y, w, h)
local out = render(e, x, y, w, h)
return out..imfs.tooltip.init(x or e.x, y or e.y, w or e.w, h or e.h, text, bgcolor, txtcolor):render()
end
end
return e
@ -248,6 +273,11 @@ local fs_tooltip = {
setmetatable(e, imfs.tooltip)
return e
end,
named = function(name, text, bgcolor, txtcolor)
local e = {_name = name, 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"))
@ -273,13 +303,6 @@ setmetatable(fs_tooltip, {
end
})
local function fs_named_tooltip(name, text, bgcolor, txtcolor)
local e = {_name = name, text = text, bgcolor = bgcolor or imfs.theme.tooltip_bg or "#444", txtcolor = txtcolor or imfs.theme.tooltip_text or "#aaa"}
setmetatable(e, fs_tooltip)
table.insert(ctx, e)
return e
end
local fs_label = {
render = function(e, x, y)
return string.format("label[%f,%f;%s]", x or get(e, "x"), y or get(e, "y"), get(e, "txt"))
@ -326,19 +349,31 @@ setmetatable(fs_arealabel, {
local fs_hypertext = {
render = function(e, x, y, w, h)
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"))
local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
out[#out +1] = 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"))
return table.concat(out)
end,
onaction = function(e, fn)
ctx._events.on_click[e.__id] = fn
return e
end,
named = function(e, name)
e.__id = name
return e
end,
styles = add_elem_styles,
style = add_elem_style,
tooltip = add_elem_tooltip,
}
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)
local e = {x = x, y = y, w = w, h = h, txt = txt, _styles = {}}
e.__id = new_id()
setmetatable(e, fs_hypertext)
table.insert(ctx, e)
return e
@ -470,6 +505,7 @@ local fs_model = {
return e
end,
style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip,
}
fs_model.__index = fs_model
@ -521,6 +557,7 @@ local fs_button = {
return e
end,
style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip,
}
fs_button.__index = fs_button
@ -557,16 +594,32 @@ setmetatable(fs_checkbox, {
local fs_list = {
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")
return string.format("list[%s;%s;%f,%f;%d,%d;%s]", get(e, "location"), get(e, "list"), x or get(e, "x"), y or get(e, "y"), w, h, get(e, "start"))
local width = (4 *w +1) /5
local height = (4 *h +1) /5
local padding_x = (w -(width *1.25) +0.25) /2
local padding_y = (h -(height *1.25) +0.25) /2
return string.format("list[%s;%s;%f,%f;%d,%d;%s]", get(e, "location"), get(e, "list"), x +padding_x, y +padding_y, width, height, get(e, "start"))
end
}
fs_list.__index = fs_list
setmetatable(fs_list, {
__call = function(_, x, y, w, h, location, list, start)
local e = {x = x, y = y, w = type(w == "string") and w or w +((w -1) /4), h = type(h) == "string" and h or h +((h -1) /4), location = location or "current_player", list = list or "main", start = start or ""}
local e = {
x = x,
y = y,
w = type(w) == "string" and w or w +((w -1) /4),
h = type(h) == "string" and h or h +((h -1) /4),
location = location or "current_player",
list = list or "main",
start = start or ""
}
e.__id = new_id()
setmetatable(e, fs_list)
table.insert(ctx, e)
@ -644,6 +697,7 @@ local fs_field = {
return e
end,
style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip,
}
fs_field.__index = fs_field
@ -665,6 +719,10 @@ end
local fs_scrollbar = {
render = function(e, x, y, w, h)
local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
if e._options then
out[#out +1] = "scrollbaroptions["
local first = true
@ -682,6 +740,11 @@ local fs_scrollbar = {
out[#out +1] = string.format("scrollbar[%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, "orientation"), e.__id, get(e, "value"))
-- If we were given custom options, reset them all so they don't interfere with future scrollbars (particularly those automatically configured by scroll containers).
if e._options then
out[#out +1] = "scrollbaroptions[min=-1;max=-1;smallstep=-1;largestep=-1;thumbsize=-1;arrows=default]"
end
return table.concat(out)
end,
options = function(e, props)
@ -693,13 +756,14 @@ local fs_scrollbar = {
return e
end,
style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip,
}
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)
e.__id = new_id()
setmetatable(e, fs_scrollbar)
ctx[#ctx +1] = e
return e
@ -741,9 +805,7 @@ local fs_scroll_container = {
end
out[#out +1] = "scroll_container_end[]"
if e._scrollbar then
out[#out +1] = e._scrollbar:render()
else
if not e._scrollbar then
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
@ -769,6 +831,7 @@ local fs_scroll_container = {
fn()
else
fs_scrollbar(fn, ...)
:options({arrows = "hide"})
end
e._scrollbar = ctx[1]
ctx = ctx.__parent
@ -806,7 +869,7 @@ setmetatable(fs_scroll_container, {
__index = ctx._events,
__newindex = ctx._events
}),
__fs = ctx.__parent or ctx
__fs = ctx.__fs or ctx
}
e.__id = new_id()
setmetatable(e, fs_scroll_container)
@ -821,6 +884,10 @@ local function fs_scroll_container_end()
minetest.log("warn", "`scroll_container_end` has no scroll container to end; it will be ignored.")
return
end
-- If the container has a custom scrollbar, we add it _after_ the container's contents.
if ctx._scrollbar then
table.insert(ctx.__parent, ctx._scrollbar)
end
ctx = ctx.__parent
end
@ -858,7 +925,7 @@ local fs_group = {
fs_group.__index = fs_group
setmetatable(fs_group, {
__call = function(_, x, y, w, h)
local e = {x = x, y = y, w = w, h = h, _gap = 0, __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, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
setmetatable(e, fs_group)
table.insert(ctx, e)
ctx = e
@ -871,6 +938,19 @@ local function fs_group_end()
minetest.log("warn", "`group_end` has no group to end; it will be ignored.")
return
end
for i = 0, 1 do
local axis = i == 0 and "w" or "h"
if ctx[axis] == "auto" then
local size = 0
for i = 1, #ctx do
local child_size = tonumber(ctx[i][axis]) or 0
if child_size > size then
size = child_size
end
end
ctx[axis] = size
end
end
ctx = ctx.__parent
end
@ -894,16 +974,14 @@ local fs_row = {
-- Pass 1: Collect total sizing information to allow layout computation.
for i = 1, #e do
local c = e[i]
if not c._no_layout then
if c.w and 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
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
@ -924,8 +1002,10 @@ local fs_row = {
if c.__flex then
c.__flex = c.__flex *grow_basis
current = current +c.__flex +e._gap
else
elseif c.w then
current = current +resolve_layout_units(get(c, e._direction == "column" and "h" or "w"), axis_size) +e._gap
else
current = current +e._gap
end
end
end
@ -956,9 +1036,9 @@ local fs_row = {
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"), w) +x, base +c.__flex_offset, resolve_layout_units(get(c, "w"), w), c.__flex)
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 or resolve_layout_units(get(c, "h"), h))
else
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))
out[#out +1] = c:render(base +c.__flex_offset, resolve_layout_units(get(c, "y"), h) +y, c.__flex or resolve_layout_units(get(c, "w"), w), resolve_layout_units(get(c, "h"), h))
end
elseif c.x then
if e._direction == "column" then
@ -987,7 +1067,7 @@ local fs_row = {
fs_row.__index = fs_row
setmetatable(fs_row, {
__call = function(_, x, y, w, h)
local e = {x = x, y = y, w = w, h = h, _gap = 0, __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, _gap = 0, __parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
setmetatable(e, fs_row)
table.insert(ctx, e)
ctx = e
@ -1001,6 +1081,17 @@ local function fs_row_end()
minetest.log("warn", "`row_end` has no row to end; it will be ignored.")
return
end
local axis = ctx._direction == "column" and "w" or "h"
if ctx[axis] == "auto" then
local size = 0
for i = 1, #ctx do
local child_size = tonumber(ctx[i][axis]) or 0
if child_size > size then
size = child_size
end
end
ctx[axis] = size
end
ctx = ctx.__parent
end
@ -1157,15 +1248,7 @@ local Context = {
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:collect_state_depends()
e._events = fs._events
@ -1178,6 +1261,22 @@ local Context = {
minetest.show_formspec(e.target, e.id, str)
end
end,
get_states_from_elem = function(e, el)
for k, 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
elseif tonumber(k) then
e:get_states_from_elem(x)
end
end
end,
collect_state_depends = function(e)
for _, el in ipairs(e._window) do
e:get_states_from_elem(el)
end
end,
clear_state_bindings = function(e)
for x in pairs(e._linked_states) do
x._getters[e.id] = nil
@ -1361,6 +1460,9 @@ imfs = {
_contexts = contexts,
_inventories = inventories,
_new_id = new_id,
_unique_id = unique_id,
state = state,
derive = derive,
get_field = get,
@ -1374,7 +1476,7 @@ imfs = {
resolve_layout_units = resolve_layout_units,
resolve_flex_layout_units = resolve_flex_layout_units,
container_start = function()
local container = {__parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__parent or ctx}
local container = {__parent = ctx, _events = setmetatable({}, {__index = ctx._events, __newindex = ctx._events}), __fs = ctx.__fs or ctx}
table.insert(ctx, container)
ctx = container
end,
@ -1389,7 +1491,6 @@ imfs = {
style = fs_style,
tooltip = fs_tooltip,
_named_tooltip = fs_named_tooltip,
label = fs_label,
arealabel = fs_arealabel,
hypertext = fs_hypertext,