forked from your-land-mirror/minetest-flow
Split code into more files
I hope I haven't broken anything
This commit is contained in:
parent
4f9e9d9a6e
commit
df077dda95
260
expand.lua
Normal file
260
expand.lua
Normal 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
|
309
input.lua
Normal file
309
input.lua
Normal 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
301
layout.lua
Normal 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
|
17
test.lua
17
test.lua
@ -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
236
widgets.lua
Normal 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
|
Loading…
Reference in New Issue
Block a user