minetest-flow/layout.lua
2025-03-03 15:13:56 +13:00

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