forked from your-land-mirror/minetest-flow
355 lines
11 KiB
Lua
355 lines
11 KiB
Lua
--
|
|
-- Flow: Layouting (initial pass)
|
|
--
|
|
-- Copyright © 2022-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 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 ASTERISK = byte("*")
|
|
local function parse_font_size(str)
|
|
-- Only support *1.1 etc for now, I don't know if the other formats are
|
|
-- used
|
|
if str and type(str) == "string" and byte(str, 1) == ASTERISK then
|
|
return tonumber(str:sub(2)) or 1
|
|
end
|
|
return 1
|
|
end
|
|
|
|
local function get_label_size(label, style, line_spacing)
|
|
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)
|
|
local font_size_frac = parse_font_size(style and style.font_size)
|
|
|
|
local font_height = font_size_frac * LABEL_HEIGHT
|
|
return longest_line_width * CHAR_WIDTH * font_size_frac,
|
|
font_height + (line_count - 1) * (line_spacing or font_height),
|
|
font_height
|
|
end
|
|
|
|
local size_getters = {}
|
|
|
|
local function parse_v2f(str, default_x, default_y)
|
|
if str and type(str) == "string" then
|
|
local x, y = str:match("^%s-(%d+)%s-,%s-(%d+)%s-$")
|
|
return tonumber(x) or default_x, tonumber(y) or default_y
|
|
end
|
|
|
|
return default_x, default_y
|
|
end
|
|
|
|
local function get_and_fill_in_sizes(node)
|
|
if node.type == "list" then
|
|
if node._flow_w and node._flow_h then
|
|
return node._flow_w, node._flow_h
|
|
end
|
|
|
|
local style = node.style
|
|
local slot_w, slot_h = parse_v2f(style and style.size, 1, 1)
|
|
local spacing_w, spacing_h = parse_v2f(
|
|
style and style.spacing, 0.25, 0.25
|
|
)
|
|
|
|
local w = node.w * (slot_w + spacing_w) - spacing_w
|
|
local h = node.h * (slot_h + spacing_h) - spacing_h
|
|
|
|
-- Cache calculated size so we don't have to parse the list style again
|
|
node._flow_w, node._flow_h = w, h
|
|
|
|
return w, h
|
|
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 style = node.style
|
|
if node.h and style and style.font_size then
|
|
core.log("warning", "[flow] Labels with a fixed height set will be " ..
|
|
"positioned as if font_size was not specified for backwards " ..
|
|
"compatibility reasons. This behaviour is deprecated, please " ..
|
|
"avoid relying on it if possible.")
|
|
style = nil
|
|
end
|
|
|
|
-- Labels always have a distance of 0.5 between each line regardless of the
|
|
-- font size
|
|
local w, h, font_height = get_label_size(node.label, style, 0.5)
|
|
node._flow_font_height = font_height
|
|
return w, h
|
|
end
|
|
|
|
local MIN_BUTTON_HEIGHT = 0.8
|
|
function size_getters.button(node)
|
|
local x, y = get_label_size(node.label, node.style)
|
|
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)
|
|
-- Field labels ignore the "font_size" style
|
|
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, node.style)
|
|
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)
|
|
-- Checkboxes don't support font_size
|
|
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 + (node._flow_font_height or LABEL_HEIGHT) / 2
|
|
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_HEIGHT, invisible_elems
|