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.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. * `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: To create a custom layouting container, the procedure is essentially as follows:
1. Create a Lua class for the container. 1. Create a Lua class for the container.
2. In the class constructor, call `imfs.container_start(container)` with the newly created container instance. 2. In the class constructor, call `imfs.container_start(container)` with the newly created container instance.

183
init.lua
View file

@ -62,7 +62,26 @@ local function add_elem_style(e, state, props)
props = state props = state
state = "default" state = "default"
end end
-- 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) 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 return e
end end
@ -75,16 +94,22 @@ local function add_elem_tooltip(e, text, enabled, bgcolor, txtcolor)
txtcolor = bgcolor txtcolor = bgcolor
bgcolor = enabled bgcolor = enabled
end 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. -- For named elements, prefer name-associated tooltips.
if e.__id then 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 local render = e.render
e.render = function(e, x, y, w, h) e.render = function(e, x, y, w, h)
local out = render(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
end end
return e return e
@ -248,6 +273,11 @@ local fs_tooltip = {
setmetatable(e, imfs.tooltip) setmetatable(e, imfs.tooltip)
return e return e
end, 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) render = function(e, x, y, w, h)
if e._name then if e._name then
return string.format("tooltip[%s;%s;%s;%s]", e._name, get(e, "text"), get(e, "bgcolor"), get(e, "txtcolor")) 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 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 = { local fs_label = {
render = function(e, x, y) 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")) 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 = { local fs_hypertext = {
render = function(e, x, y, w, h) 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, end,
onaction = function(e, fn) onaction = function(e, fn)
ctx._events.on_click[e.__id] = fn ctx._events.on_click[e.__id] = fn
return e return e
end, end,
named = function(e, name)
e.__id = name
return e
end,
styles = add_elem_styles,
style = add_elem_style,
tooltip = add_elem_tooltip, tooltip = add_elem_tooltip,
} }
fs_hypertext.__index = fs_hypertext fs_hypertext.__index = fs_hypertext
setmetatable(fs_hypertext, { setmetatable(fs_hypertext, {
__call = function(_, x, y, w, h, txt) __call = function(_, x, y, w, h, txt)
local e = {x = x, y = y, w = w, h = h, txt = txt} local e = {x = x, y = y, w = w, h = h, txt = txt, _styles = {}}
e.__id = "_"..minetest.get_us_time().."_"..math.random(1, 100000) e.__id = new_id()
setmetatable(e, fs_hypertext) setmetatable(e, fs_hypertext)
table.insert(ctx, e) table.insert(ctx, e)
return e return e
@ -470,6 +505,7 @@ local fs_model = {
return e return e
end, end,
style = add_elem_style, style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip, tooltip = add_elem_tooltip,
} }
fs_model.__index = fs_model fs_model.__index = fs_model
@ -521,6 +557,7 @@ local fs_button = {
return e return e
end, end,
style = add_elem_style, style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip, tooltip = add_elem_tooltip,
} }
fs_button.__index = fs_button fs_button.__index = fs_button
@ -557,16 +594,32 @@ setmetatable(fs_checkbox, {
local fs_list = { local fs_list = {
render = function(e, x, y, w, h) 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") w = w or get(e, "w")
h = h or get(e, "h") 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 end
} }
fs_list.__index = fs_list fs_list.__index = fs_list
setmetatable(fs_list, { setmetatable(fs_list, {
__call = function(_, x, y, w, h, location, list, start) __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() e.__id = new_id()
setmetatable(e, fs_list) setmetatable(e, fs_list)
table.insert(ctx, e) table.insert(ctx, e)
@ -644,6 +697,7 @@ local fs_field = {
return e return e
end, end,
style = add_elem_style, style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip, tooltip = add_elem_tooltip,
} }
fs_field.__index = fs_field fs_field.__index = fs_field
@ -665,6 +719,10 @@ end
local fs_scrollbar = { local fs_scrollbar = {
render = function(e, x, y, w, h) render = function(e, x, y, w, h)
local out = {} local out = {}
for _, x in pairs(e._styles) do
out[#out +1] = x:render()
end
if e._options then if e._options then
out[#out +1] = "scrollbaroptions[" out[#out +1] = "scrollbaroptions["
local first = true 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")) 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) return table.concat(out)
end, end,
options = function(e, props) options = function(e, props)
@ -693,13 +756,14 @@ local fs_scrollbar = {
return e return e
end, end,
style = add_elem_style, style = add_elem_style,
styles = add_elem_styles,
tooltip = add_elem_tooltip, tooltip = add_elem_tooltip,
} }
fs_scrollbar.__index = fs_scrollbar fs_scrollbar.__index = fs_scrollbar
setmetatable(fs_scrollbar, { setmetatable(fs_scrollbar, {
__call = function(_, x, y, w, h, orientation, value) __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 = {}} 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) setmetatable(e, fs_scrollbar)
ctx[#ctx +1] = e ctx[#ctx +1] = e
return e return e
@ -741,9 +805,7 @@ local fs_scroll_container = {
end end
out[#out +1] = "scroll_container_end[]" out[#out +1] = "scroll_container_end[]"
if e._scrollbar then if not e._scrollbar then
out[#out +1] = e._scrollbar:render()
else
local v = e.__fs._ctx.fields[e.__id] 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 "") 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 end
@ -769,6 +831,7 @@ local fs_scroll_container = {
fn() fn()
else else
fs_scrollbar(fn, ...) fs_scrollbar(fn, ...)
:options({arrows = "hide"})
end end
e._scrollbar = ctx[1] e._scrollbar = ctx[1]
ctx = ctx.__parent ctx = ctx.__parent
@ -806,7 +869,7 @@ setmetatable(fs_scroll_container, {
__index = ctx._events, __index = ctx._events,
__newindex = ctx._events __newindex = ctx._events
}), }),
__fs = ctx.__parent or ctx __fs = ctx.__fs or ctx
} }
e.__id = new_id() e.__id = new_id()
setmetatable(e, fs_scroll_container) 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.") minetest.log("warn", "`scroll_container_end` has no scroll container to end; it will be ignored.")
return return
end 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 ctx = ctx.__parent
end end
@ -858,7 +925,7 @@ local fs_group = {
fs_group.__index = fs_group fs_group.__index = fs_group
setmetatable(fs_group, { setmetatable(fs_group, {
__call = function(_, x, y, w, h) __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) setmetatable(e, fs_group)
table.insert(ctx, e) table.insert(ctx, e)
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.") minetest.log("warn", "`group_end` has no group to end; it will be ignored.")
return return
end 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 ctx = ctx.__parent
end end
@ -894,9 +974,8 @@ local fs_row = {
-- Pass 1: Collect total sizing information to allow layout computation. -- Pass 1: Collect total sizing information to allow layout computation.
for i = 1, #e do for i = 1, #e do
local c = e[i] 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) 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 if ca_flex then
flex_found = true flex_found = true
c.__flex = ca c.__flex = ca
@ -906,7 +985,6 @@ local fs_row = {
end end
end end
end end
end
used_space = used_space +(e._gap *math.max(0, #e -1)) used_space = used_space +(e._gap *math.max(0, #e -1))
local grow_basis = total_grow > 0 and (math.max(0, axis_size -used_space) /total_grow) or 0 local grow_basis = total_grow > 0 and (math.max(0, axis_size -used_space) /total_grow) or 0
@ -924,8 +1002,10 @@ local fs_row = {
if c.__flex then if c.__flex then
c.__flex = c.__flex *grow_basis c.__flex = c.__flex *grow_basis
current = current +c.__flex +e._gap 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 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 end
end end
@ -956,9 +1036,9 @@ local fs_row = {
out[#out +1] = c:render(cx, cy, cw, ch) out[#out +1] = c:render(cx, cy, cw, ch)
elseif c.w then elseif c.w then
if e._direction == "column" 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 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 end
elseif c.x then elseif c.x then
if e._direction == "column" then if e._direction == "column" then
@ -987,7 +1067,7 @@ local fs_row = {
fs_row.__index = fs_row fs_row.__index = fs_row
setmetatable(fs_row, { setmetatable(fs_row, {
__call = function(_, x, y, w, h) __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) setmetatable(e, fs_row)
table.insert(ctx, e) table.insert(ctx, e)
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.") minetest.log("warn", "`row_end` has no row to end; it will be ignored.")
return return
end 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 ctx = ctx.__parent
end end
@ -1157,15 +1248,7 @@ local Context = {
fs._ctx = e fs._ctx = e
for _, el in ipairs(fs) do e:collect_state_depends()
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 e._events = fs._events
@ -1178,6 +1261,22 @@ local Context = {
minetest.show_formspec(e.target, e.id, str) minetest.show_formspec(e.target, e.id, str)
end end
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) clear_state_bindings = function(e)
for x in pairs(e._linked_states) do for x in pairs(e._linked_states) do
x._getters[e.id] = nil x._getters[e.id] = nil
@ -1361,6 +1460,9 @@ imfs = {
_contexts = contexts, _contexts = contexts,
_inventories = inventories, _inventories = inventories,
_new_id = new_id,
_unique_id = unique_id,
state = state, state = state,
derive = derive, derive = derive,
get_field = get, get_field = get,
@ -1374,7 +1476,7 @@ imfs = {
resolve_layout_units = resolve_layout_units, resolve_layout_units = resolve_layout_units,
resolve_flex_layout_units = resolve_flex_layout_units, resolve_flex_layout_units = resolve_flex_layout_units,
container_start = function() 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) table.insert(ctx, container)
ctx = container ctx = container
end, end,
@ -1389,7 +1491,6 @@ imfs = {
style = fs_style, style = fs_style,
tooltip = fs_tooltip, tooltip = fs_tooltip,
_named_tooltip = fs_named_tooltip,
label = fs_label, label = fs_label,
arealabel = fs_arealabel, arealabel = fs_arealabel,
hypertext = fs_hypertext, hypertext = fs_hypertext,