Split code into more files

I hope I haven't broken anything
This commit is contained in:
luk3yx 2025-02-21 16:39:51 +13:00
parent 4f9e9d9a6e
commit df077dda95
6 changed files with 1136 additions and 1029 deletions

260
expand.lua Normal file
View File

@ -0,0 +1,260 @@
--
-- Flow: Layout expansion/stretching pass
--
-- Copyright © 2025 by luk3yx
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Lesser General Public License as published by
-- the Free Software Foundation, either version 2.1 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Lesser General Public License for more details.
--
-- You should have received a copy of the GNU Lesser General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--
local DEFAULT_SPACING, LABEL_OFFSET, invisible_elems = ...
local align_types = {}
function align_types.fill(node, x, w, extra_space)
-- Special cases
if node.type == "list" or node.type == "checkbox" or node._label_hack then
return align_types.centre(node, x, w, extra_space)
elseif node.type == "label" then
if x == "y" then
node.y = node.y + extra_space / 2
return
end
-- Hack
node.type = "container"
-- Reset bgimg, some games apply styling to all image_buttons inside
-- the formspec prepend
node[1] = {
type = "style",
-- MT 5.1.0 only supports one style selector
selectors = {"_#"},
-- bgimg_pressed is included for 5.1.0 support
-- bgimg_hovered is unnecessary as it was added in 5.2.0 (which
-- also adds support for :hovered and :pressed)
props = {bgimg = "", bgimg_pressed = ""},
}
-- Use the newer pressed selector as well in case the deprecated one is
-- removed
node[2] = {
type = "style",
selectors = {"_#:hovered", "_#:pressed"},
props = {bgimg = ""},
}
node[3] = {
type = "image_button",
texture_name = "blank.png",
drawborder = false,
x = 0, y = 0,
w = node.w + extra_space, h = node.h,
name = "_#", label = node.label,
style = node.style,
}
-- Overlay button to prevent clicks from doing anything
node[4] = {
type = "image_button",
texture_name = "blank.png",
drawborder = false,
x = 0, y = 0,
w = node.w + extra_space, h = node.h,
name = "_#", label = "",
}
node.y = node.y - LABEL_OFFSET
node.label = nil
node.style = nil
node._label_hack = true
assert(#node == 4)
end
if node[w] then
node[w] = node[w] + extra_space
else
core.log("warning", "[flow] Unknown element: \"" ..
tostring(node.type) .. "\". Please make sure that flow is " ..
"up-to-date and the element has a size set (if required).")
node[w] = extra_space
end
end
function align_types.start()
-- No alterations required
end
-- "end" is a Lua keyword
align_types["end"] = function(node, x, _, extra_space)
node[x] = node[x] + extra_space
end
-- Aliases for convenience
align_types.top, align_types.bottom = align_types.start, align_types["end"]
align_types.left, align_types.right = align_types.start, align_types["end"]
function align_types.centre(node, x, w, extra_space)
if node.type == "label" then
return align_types.fill(node, x, w, extra_space)
elseif node.type == "checkbox" and x == "y" then
node.y = (node.h + extra_space) / 2
return
end
node[x] = node[x] + extra_space / 2
end
align_types.center = align_types.centre
-- Try to guess at what the best expansion setting is
local auto_align_centre = {
image = true, animated_image = true, model = true, item_image_button = true
}
function align_types.auto(node, x, w, extra_space, cross)
if auto_align_centre[node.type] then
return align_types.centre(node, x, w, extra_space)
end
if x == "y" or (node.type ~= "label" and node.type ~= "checkbox") or
(node.expand and not cross) then
return align_types.fill(node, x, w, extra_space)
end
end
local expand_child_boxes
local function expand(box)
local x, w, align_h, y, h, align_v
local box_type = box.type
if box_type == "hbox" then
x, w, align_h, y, h, align_v = "x", "w", "align_h", "y", "h", "align_v"
elseif box_type == "vbox" then
x, w, align_h, y, h, align_v = "y", "h", "align_v", "x", "w", "align_h"
elseif box_type == "stack" or
(box_type == "padding" and box[1].expand) then
box.type = "container"
box._enable_bgimg_hack = true
for _, node in ipairs(box) do
if not invisible_elems[node.type] then
local width, height = node.w or 0, node.h or 0
if node.type == "list" then
width = width * 1.25 - 0.25
height = height * 1.25 - 0.25
end
local padding_x2 = (node.padding or 0) * 2
align_types[node.align_h or "auto"](node, "x", "w", box.w -
width - padding_x2)
align_types[node.align_v or "auto"](node, "y", "h", box.h -
height - padding_x2 - (node._padding_top or 0))
end
end
return expand_child_boxes(box)
elseif box_type == "container" or box_type == "scroll_container" then
for _, node in ipairs(box) do
if node.x == 0 and node.expand and box.w then
node.w = box.w
end
expand(node)
end
return
elseif box_type == "padding" then
box.type = "container"
return expand_child_boxes(box)
else
return
end
box.type = "container"
-- Calculate the amount of free space and put expand nodes into a table
local box_h = box[h]
local free_space = box[w]
local expandable = {}
local expand_count = 0
local first = true
for i, node in ipairs(box) do
local width, height = node[w] or 0, node[h] or 0
if not invisible_elems[node.type] then
if first then
first = false
else
free_space = free_space - (box.spacing or DEFAULT_SPACING)
end
if node.type == "list" then
width = width * 1.25 - 0.25
height = height * 1.25 - 0.25
end
free_space = free_space - width - (node.padding or 0) * 2 -
(y == "x" and node._padding_top or 0)
if node.expand then
expandable[node] = i
expand_count = expand_count + 1
elseif node.type == "label" and align_h == "align_h" then
-- Use the image_button hack even if the label isn't expanded
align_types[node.align_h or "auto"](node, "x", "w", 0)
end
-- Nodes are expanded in the other direction no matter what their
-- expand setting is
if box_h > height or (node.type == "label" and
align_v == "align_h") then
align_types[node[align_v] or "auto"](node, y, h,
box_h - height - (node.padding or 0) * 2 -
(y == "y" and node._padding_top or 0), true)
end
end
end
-- If there's any free space then expand the nodes to fit
if free_space > 0 then
local extra_space = free_space / expand_count
for node, node_idx in pairs(expandable) do
align_types[node[align_h] or "auto"](node, x, w, extra_space)
-- Shift other elements along
for j = node_idx + 1, #box do
if box[j][x] then
box[j][x] = box[j][x] + extra_space
end
end
end
elseif align_h == "align_h" then
-- Use the image_button hack on labels regardless of the amount of free
-- space if this is in a horizontal box.
for node in pairs(expandable) do
if node.type == "label" then
align_types[node.align_h or "auto"](node, "x", "w", 0)
end
end
end
expand_child_boxes(box)
end
function expand_child_boxes(box)
-- Recursively expand and remove any invisible nodes
for i = #box, 1, -1 do
local node = box[i]
-- node.visible ~= nil and not node.visible
if node.visible == false then
-- There's no need to try and expand anything inside invisible
-- nodes since it won't affect the overall size.
table.remove(box, i)
else
expand(node)
end
end
end
return expand

1042
init.lua

File diff suppressed because it is too large Load Diff

309
input.lua Normal file
View File

@ -0,0 +1,309 @@
--
-- Flow: Formspec input processor
--
-- Copyright © 2025 by luk3yx
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Lesser General Public License as published by
-- the Free Software Foundation, either version 2.1 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Lesser General Public License for more details.
--
-- You should have received a copy of the GNU Lesser General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--
local ceil, floor, max = math.ceil, math.floor, math.max
local function chain_cb(f1, f2)
return function(...)
f1(...)
f2(...)
end
end
local function range_check_transformer(items_length)
return function(value)
local num = tonumber(value)
if num and num == num then
num = floor(num)
if num >= 1 and num <= items_length then
return num
end
end
end
end
local function simple_transformer(func)
return function() return func end
end
-- Functions that transform field values into the easiest to use type
local C1_CHARS = "\194[\128-\159]"
local field_value_transformers = {
field = simple_transformer(function(value)
-- Remove control characters and newlines
return value:gsub("[%z\1-\8\10-\31\127]", ""):gsub(C1_CHARS, "")
end),
checkbox = simple_transformer(core.is_yes),
-- Scrollbars do have min/max values but scrollbars are only really used by
-- ScrollableVBox which doesn't need the extra checks
scrollbar = simple_transformer(function(value)
return core.explode_scrollbar_event(value).value
end),
}
-- Field value transformers that depend on some property of the element
function field_value_transformers.tabheader(node)
return range_check_transformer(node.captions and #node.captions or 0)
end
function field_value_transformers.dropdown(node, _, formspec_version)
local items = node.items or {}
if node.index_event and not node._index_event_hack then
return range_check_transformer(#items)
end
-- MT will start sanitising formspec fields on its own at some point
-- (https://github.com/minetest/minetest/pull/14878), however it may strip
-- escape sequences from dropdowns as well. Since we know what the actual
-- value of the dropdown is anyway, we can just enable index_event for new
-- clients and keep the same behaviour
if (formspec_version and formspec_version >= 4) or
(core.global_exists("fs51") and
fs51.monkey_patching_enabled) then
node.index_event = true
-- Detect reuse of the same Dropdown element (this is unsupported and
-- will break in other ways)
node._index_event_hack = true
return function(value)
return items[tonumber(value)]
end
elseif node._index_event_hack then
node.index_event = nil
end
-- Make sure that the value sent by the client is in the list of items
return function(value)
if table.indexof(items, value) > 0 then
return value
end
end
end
function field_value_transformers.table(node, tablecolumn_count)
-- Figure out how many rows the table has
local cells = node.cells and #node.cells or 0
local rows = ceil(cells / tablecolumn_count)
return function(value)
local row = floor(core.explode_table_event(value).row)
-- Tables and textlists can have values of 0 (nothing selected) but I
-- don't think the client can un-select a row so it should be safe to
-- ignore any 0 sent by the client to guarantee that the row will be
-- valid if the default value is valid
if row >= 1 and row <= rows then
return row
end
end
end
function field_value_transformers.textlist(node)
local rows = node.listelems and #node.listelems or 0
return function(value)
local index = floor(core.explode_textlist_event(value).index)
if index >= 1 and index <= rows then
return index
end
end
end
local function default_field_value_transformer(value)
-- Remove control characters (but preserve newlines)
-- Pattern by https://github.com/appgurueu
return value:gsub("[%z\1-\8\11-\31\127]", ""):gsub(C1_CHARS, "")
end
local default_value_fields = {
field = "default",
pwdfield = "default",
textarea = "default",
checkbox = "selected",
dropdown = "selected_idx",
table = "selected_idx",
textlist = "selected_idx",
scrollbar = "value",
tabheader = "current_tab",
}
local sensible_defaults = {
default = "", selected = false, selected_idx = 1, value = 0,
}
local button_types = {
button = true, image_button = true, item_image_button = true,
button_exit = true, image_button_exit = true
}
-- Removes on_event from a formspec_ast tree and returns a callbacks table
local function parse_callbacks(tree, ctx_form, auto_name_id,
replace_backgrounds, formspec_version)
local callbacks
local btn_callbacks = {}
local saved_fields = {}
local tablecolumn_count = 1
for node in formspec_ast.walk(tree) do
if node.type == "container" then
if node.bgcolor then
local padding = node.padding or 0
table.insert(node, 1, {
type = "box", color = node.bgcolor,
x = -padding, y = -padding,
w = node.w + padding * 2, h = node.h + padding * 2,
})
end
if node.bgimg then
local padding = node.padding or 0
table.insert(node, 1, {
type = node.bgimg_middle and "background9" or "background",
texture_name = node.bgimg, middle_x = node.bgimg_middle,
x = -padding, y = -padding,
w = node.w + padding * 2, h = node.h + padding * 2,
})
end
-- The on_quit callback is undocumented and not recommended, it
-- only gets called when the client tells the server that it's
-- closing the form and not when another form is shown.
if node.on_quit then
callbacks = callbacks or {}
if callbacks.quit then
-- HACK
callbacks.quit = chain_cb(callbacks.quit, node.on_quit)
else
callbacks.quit = node.on_quit
end
end
replace_backgrounds = replace_backgrounds or node._enable_bgimg_hack
elseif node.type == "tablecolumns" and node.tablecolumns then
-- Store the amount of columns for input validation
tablecolumn_count = max(#node.tablecolumns, 1)
elseif replace_backgrounds then
if (node.type == "background" or node.type == "background9") and
not node.auto_clip then
node.type = "image"
end
elseif node.type == "scroll_container" then
-- Work around a Minetest bug with scroll containers not scrolling
-- backgrounds.
replace_backgrounds = true
end
local node_name = node.name
if node_name and node_name ~= "" then
local value_field = default_value_fields[node.type]
if value_field then
-- Update ctx.form if there is no current value, otherwise
-- change the node's value to the saved one.
local value = ctx_form[node_name]
if node.type == "dropdown" and (not node.index_event or
node._index_event_hack) then
-- Special case for dropdowns without index_event
local items = node.items or {}
if value == nil then
ctx_form[node_name] = items[node.selected_idx or 1]
else
local idx = table.indexof(items, value)
if idx > 0 then
node.selected_idx = idx
end
end
node.selected_idx = node.selected_idx or 1
elseif value == nil then
-- If ctx.form[node_name] doesn't exist, then check whether
-- a default value is specified.
local default_value = node[value_field]
local sensible_default = sensible_defaults[value_field]
if default_value == nil then
-- If the element doesn't have a default set, set it to
-- the sensible default value and update ctx.form in
-- case the client doesn't send the field value back.
node[value_field] = sensible_default
ctx_form[node_name] = sensible_default
else
-- Update ctx.form to the default value
ctx_form[node_name] = default_value
end
else
-- Set the node's value to the one saved in ctx.form
node[value_field] = value
end
-- Add the corresponding value transformer transformer to
-- saved_fields
local get_transformer = field_value_transformers[node.type]
saved_fields[node_name] = get_transformer and
get_transformer(node, tablecolumn_count,
formspec_version) or
default_field_value_transformer
elseif node.type == "hypertext" then
-- Experimental (may be broken in the future): Allow accessing
-- hypertext fields with "ctx.form.hypertext_name" as this is
-- the most straightforward way of doing it.
saved_fields[node_name] = default_field_value_transformer
end
end
-- Add the on_event callback (if any) to the callbacks table
if node.on_event then
local is_btn = button_types[node.type]
if not node_name then
-- Flow internal field names start with "_#" to avoid
-- conflicts with user-provided fields.
node_name = ("_#%x"):format(auto_name_id)
node.name = node_name
auto_name_id = auto_name_id + 1
elseif btn_callbacks[node_name] or
(is_btn and saved_fields[node_name]) or
(callbacks and callbacks[node_name]) then
core.log("warning", ("[flow] Multiple callbacks have " ..
"been registered for elements with the same name (%q), " ..
"this will not work properly."):format(node_name))
-- Preserve previous behaviour
btn_callbacks[node_name] = nil
if callbacks then
callbacks[node_name] = nil
end
is_btn = is_btn and not saved_fields[node_name]
end
-- Put buttons into a separate callback table so that malicious
-- clients can't send multiple button presses in one submission
if is_btn then
btn_callbacks[node_name] = node.on_event
else
callbacks = callbacks or {}
callbacks[node_name] = node.on_event
end
node.on_event = nil
end
-- Call _after_positioned (used internally for ScrollableVBox)
if node._after_positioned then
node:_after_positioned()
node._after_positioned = nil
end
end
return callbacks, btn_callbacks, saved_fields, auto_name_id
end
return parse_callbacks

301
layout.lua Normal file
View File

@ -0,0 +1,301 @@
--
-- Flow: Layouting (initial pass)
--
-- Copyright © 2025 by luk3yx
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Lesser General Public License as published by
-- the Free Software Foundation, either version 2.1 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Lesser General Public License for more details.
--
-- You should have received a copy of the GNU Lesser General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--
local max = math.max
-- Estimates the width of a valid UTF-8 string, ignoring any escape sequences.
-- This function hopefully works with most (but not all) scripts, maybe it
-- could still be improved.
local byte, strlen = string.byte, string.len
local LPAREN = byte("(")
local function naive_str_width(str)
local w = 0
local prev_w = 0
local line_count = 1
local i = 1
-- string.len() is used so that numbers are coerced to strings without any
-- extra checking
local str_length = strlen(str)
while i <= str_length do
local char = byte(str, i)
if char == 0x1b then
-- Ignore escape sequences
i = i + 1
if byte(str, i) == LPAREN then
i = str:find(")", i + 1, true) or str_length
end
elseif char == 0xe1 then
if (byte(str, i + 1) or 0) < 0x84 then
-- U+1000 - U+10FF
w = w + 1
else
-- U+1100 - U+2000
w = w + 2
end
i = i + 2
elseif char > 0xe1 and char < 0xf5 then
-- U+2000 - U+10FFFF
w = w + 2
i = i + 2
elseif char == 0x0a then
-- Newlines: Reset the width and increase the line count
prev_w = max(prev_w, w)
w = 0
line_count = line_count + 1
elseif char < 0x80 or char > 0xbf then
-- Everything except UTF-8 continuation sequences
w = w + 1
end
i = i + 1
end
return max(w, prev_w), line_count
end
local LABEL_HEIGHT = 0.4
local LABEL_OFFSET = LABEL_HEIGHT / 2
local CHAR_WIDTH = 0.21
-- The "current_lang" variable isn't ideal but means that the language will be
-- known inside ScrollableVBox etc
local current_lang
-- get_translated_string doesn't exist in MT 5.2.0 and older
local get_translated_string = core.get_translated_string or function(_, s)
return s
end
local function get_lines_size(lines)
local w = 0
for _, line in ipairs(lines) do
-- Translate the string if necessary
if current_lang and current_lang ~= "" and current_lang ~= "en" then
line = get_translated_string(current_lang, line)
end
w = max(w, naive_str_width(line) * CHAR_WIDTH)
end
return w, LABEL_HEIGHT * #lines
end
local function get_label_size(label)
label = label or ""
if current_lang and current_lang ~= "" and current_lang ~= "en" then
label = get_translated_string(current_lang, label)
end
local longest_line_width, line_count = naive_str_width(label)
return longest_line_width * CHAR_WIDTH, line_count * LABEL_HEIGHT
end
local size_getters = {}
local function get_and_fill_in_sizes(node)
if node.type == "list" then
return node.w * 1.25 - 0.25, node.h * 1.25 - 0.25
end
if node.w and node.h then
return node.w, node.h
end
local f = size_getters[node.type]
if not f then return 0, 0 end
local w, h = f(node)
node.w = node.w or max(w, node.min_w or 0)
node.h = node.h or max(h, node.min_h or 0)
return node.w, node.h
end
function size_getters.container(node)
local w, h = 0, 0
for _, n in ipairs(node) do
local w2, h2 = get_and_fill_in_sizes(n)
w = max(w, (n.x or 0) + w2)
h = max(h, (n.y or 0) + h2)
end
return w, h
end
size_getters.scroll_container = size_getters.container
function size_getters.label(node)
local w, h = get_label_size(node.label)
return w, LABEL_HEIGHT + (h - LABEL_HEIGHT) * 1.25
end
local MIN_BUTTON_HEIGHT = 0.8
function size_getters.button(node)
local x, y = get_label_size(node.label)
return max(x, MIN_BUTTON_HEIGHT * 2), max(y, MIN_BUTTON_HEIGHT)
end
size_getters.button_exit = size_getters.button
size_getters.image_button = size_getters.button
size_getters.image_button_exit = size_getters.button
size_getters.item_image_button = size_getters.button
size_getters.button_url = size_getters.button
function size_getters.field(node)
local label_w, label_h = get_label_size(node.label)
-- This is done in apply_padding as well but the label size has already
-- been calculated here
if not node._padding_top and node.label and #node.label > 0 then
node._padding_top = label_h
end
local w, h = get_label_size(node.default)
return max(w, label_w, 3), max(h, MIN_BUTTON_HEIGHT)
end
size_getters.pwdfield = size_getters.field
size_getters.textarea = size_getters.field
function size_getters.vertlabel(node)
return CHAR_WIDTH, #node.label * LABEL_HEIGHT
end
function size_getters.textlist(node)
local w, h = get_lines_size(node.listelems)
return w, h * 1.1
end
function size_getters.dropdown(node)
return max(get_lines_size(node.items) + 0.3, 2), MIN_BUTTON_HEIGHT
end
function size_getters.checkbox(node)
local w, h = get_label_size(node.label)
return w + 0.4, h
end
local field_elems = {field = true, pwdfield = true, textarea = true}
local function apply_padding(node, x, y)
local w, h = get_and_fill_in_sizes(node)
-- Labels are positioned from the centre of the first line and checkboxes
-- are positioned from the centre.
if node.type == "label" then
y = y + LABEL_OFFSET
elseif node.type == "checkbox" then
y = y + h / 2
elseif field_elems[node.type] and not node._padding_top and node.label and
#node.label > 0 then
-- Add _padding_top to fields with labels that have a fixed size set
local _, label_h = get_label_size(node.label)
node._padding_top = label_h
elseif node.type == "tabheader" and w > 0 and h > 0 then
-- Handle tabheader if the width and height are set
-- I'm not sure what to do with tabheaders that don't have a width or
-- height set.
y = y + h
end
if node._padding_top then
y = y + node._padding_top
h = h + node._padding_top
end
local padding = node.padding
if padding then
x = x + padding
y = y + padding
w = w + padding * 2
h = h + padding * 2
end
node.x, node.y = x, y
return w, h
end
local invisible_elems = {
style = true, listring = true, scrollbaroptions = true, tableoptions = true,
tablecolumns = true, tooltip = true, style_type = true, set_focus = true,
listcolors = true
}
local DEFAULT_SPACING = 0.2
function size_getters.vbox(vbox)
local spacing = vbox.spacing or DEFAULT_SPACING
local width = 0
local y = 0
for _, node in ipairs(vbox) do
if not invisible_elems[node.type] then
if y > 0 then
y = y + spacing
end
local w, h = apply_padding(node, 0, y)
width = max(width, w)
y = y + h
end
end
return width, y
end
function size_getters.hbox(hbox)
local spacing = hbox.spacing or DEFAULT_SPACING
local x = 0
local height = 0
for _, node in ipairs(hbox) do
if not invisible_elems[node.type] then
if x > 0 then
x = x + spacing
end
local w, h = apply_padding(node, x, 0)
height = max(height, h)
x = x + w
end
end
return x, height
end
function size_getters.stack(stack)
local width, height = 0, 0
for _, node in ipairs(stack) do
if not invisible_elems[node.type] then
local w, h = apply_padding(node, 0, 0)
width = max(width, w)
height = max(height, h)
end
end
return width, height
end
function size_getters.padding(node)
core.log("warning", "[flow] The gui.Padding element is deprecated")
assert(#node == 1, "Padding can only have one element inside.")
local n = node[1]
local x, y = apply_padding(n, 0, 0)
if node.expand == nil then
node.expand = n.expand
end
return x, y
end
local function set_current_lang(lang)
current_lang = lang
end
return apply_padding, get_and_fill_in_sizes, set_current_lang,
DEFAULT_SPACING, LABEL_OFFSET, invisible_elems

View File

@ -118,10 +118,19 @@ function core.get_player_information(name)
end
-- Load flow
local f = assert(io.open("init.lua"))
local code = f:read("*a") .. "\nreturn naive_str_width"
f:close()
local naive_str_width = assert((loadstring or load)(code))()
dofile("init.lua")
-- Unfortunately the easiest way of getting naive_str_width without adding
-- runtime checks is to load layout.lua twice. Luckily it is somewhat self
-- contained so this shouldn't be a problem.
local naive_str_width
do
local f = assert(io.open("layout.lua"))
local code = f:read("*a"):gsub("\nreturn",
"\nreturn naive_str_width --[[") .. "]]"
f:close()
naive_str_width = assert((loadstring or load)(code))()
end
local gui = flow.widgets

236
widgets.lua Normal file
View File

@ -0,0 +1,236 @@
--
-- Flow: Widgets
--
-- Copyright © 2025 by luk3yx
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Lesser General Public License as published by
-- the Free Software Foundation, either version 2.1 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Lesser General Public License for more details.
--
-- You should have received a copy of the GNU Lesser General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--
local S = core.get_translator("flow")
local min, max = math.min, math.max
local DEFAULT_SPACING, get_and_fill_in_sizes = ...
local gui_mt = {
__index = function(gui, k)
local elem_type = k
if elem_type ~= "ScrollbarOptions" and elem_type ~= "TableOptions" and
elem_type ~= "TableColumns" then
elem_type = elem_type:gsub("([a-z])([A-Z])", function(a, b)
return a .. "_" .. b
end)
end
elem_type = elem_type:lower()
local function f(t)
t.type = elem_type
return t
end
rawset(gui, k, f)
return f
end,
}
local gui = setmetatable({
embed = function(fs, w, h)
core.log("warning", "[flow] gui.embed() is deprecated")
if type(fs) ~= "table" then
fs = formspec_ast.parse(fs)
end
fs.type = "container"
fs.w = w
fs.h = h
return fs
end,
formspec_version = 0,
}, gui_mt)
flow.widgets = gui
-- Extra GUI elements
-- Please don't modify the gui table in your own code
function gui.PaginatedVBox(def)
local w, h = def.w, def.h
def.w, def.h = nil, nil
local paginator_name = "_paginator-" .. assert(def.name)
def.type = "vbox"
local inner_w, inner_h = get_and_fill_in_sizes(def)
h = h or min(inner_h, 5)
local ctx = flow.get_context()
-- Build a list of pages
local page = {}
local pages = {page}
local max_y = h
for _, node in ipairs(def) do
if node.y and node.y + (node.h or 0) > max_y then
-- Something overflowed, go to a new page
page = {}
pages[#pages + 1] = page
max_y = node.y + h
end
-- Add to the current page
node.x, node.y = nil, nil
page[#page + 1] = node
end
-- Get the current page
local current_page = ctx.form[paginator_name] or 1
if current_page > #pages then
current_page = #pages
ctx.form[paginator_name] = current_page
end
page = pages[current_page] or {}
page.h = h
return gui.VBox {
min_w = w or inner_w,
gui.VBox(page),
gui.HBox {
gui.Button {
label = "<",
on_event = function(_, ctx)
ctx.form[paginator_name] = max(current_page - 1, 1)
return true
end,
},
gui.Label {
label = S("Page @1 of @2", current_page, #pages),
align_h = "centre",
expand = true,
},
gui.Button {
label = ">",
on_event = function(_, ctx)
ctx.form[paginator_name] = current_page + 1
return true
end,
},
}
}
end
function gui.ScrollableVBox(def)
-- On older clients fall back to a paginated vbox
if gui.formspec_version < 4 then
return gui.PaginatedVBox(def)
end
local w, h = def.w, def.h
local scrollbar_name = "_scrollbar-" .. assert(
def.name, "Please provide a name for all ScrollableVBox elements!"
)
local align_h, align_v, expand_box = def.align_h, def.align_v, def.expand
def.type = "vbox"
def.x, def.y = 0, 0
def.w, def.h = nil, nil
local inner_w, inner_h = get_and_fill_in_sizes(def)
def.w = w or inner_w
def.expand = true
h = h or min(inner_h, 5)
local scrollbar = {
w = 0.5, h = 0.5, orientation = "vertical",
name = scrollbar_name,
}
-- Allow properties of the scrollbar (such as the width) to be overridden
if def.custom_scrollbar then
for k, v in pairs(def.custom_scrollbar) do
scrollbar[k] = v
end
end
local opts = {}
return gui.HBox{
align_h = align_h,
align_v = align_v,
expand = expand_box,
gui.ScrollContainer{
expand = true,
w = w or inner_w,
h = h,
scrollbar_name = scrollbar_name,
orientation = "vertical",
def,
-- Calculate the scrollbar maximum after the scroll container is
-- expanded
_after_positioned = function(self)
opts.max = max(inner_h - self.h + 0.05, 0) * 10
opts.thumbsize = (self.h / inner_h) * (inner_h - self.h) * 10
end,
},
gui.ScrollbarOptions{opts = opts},
gui.Scrollbar(scrollbar)
}
end
function gui.Flow(def)
local vbox = {
type = "vbox",
bgcolor = def.bgcolor,
bgimg = def.bgimg,
align_h = "centre",
align_v = "centre",
}
local width = assert(def.w)
local spacing = def.spacing or DEFAULT_SPACING
local line = {spacing = spacing}
for _, node in ipairs(def) do
local w = get_and_fill_in_sizes(node)
if w > width then
width = def.w
vbox[#vbox + 1] = gui.HBox(line)
line = {spacing = spacing}
end
line[#line + 1] = node
width = width - w - spacing
end
vbox[#vbox + 1] = gui.HBox(line)
return vbox
end
function gui.Spacer(def)
def.type = "container"
assert(#def == 0)
-- Spacers default to expanding
if def.expand == nil then
def.expand = true
end
-- Prevent an empty container from being added to the resulting form
def.visible = false
return def
end
-- For use in inline <bool> and <a> or <b> type inline ifs
function gui.Nil(def)
-- Tooltip elements are ignored when layouting and setting visible = false
-- ensures that the element won't get added to the resulting formspec
def.visible = false
return gui.Tooltip(def)
end
-- Prevent any further modifications to the gui table
function gui_mt.__newindex()
error("Cannot modifiy gui table")
end