From e3a4f2684261e35ea8fe1ae642a8f080788af7db Mon Sep 17 00:00:00 2001 From: v-rob Date: Mon, 7 Aug 2023 20:59:47 -0700 Subject: [PATCH] Create Lua frontend GUI code --- .luacheckrc | 9 +- builtin/common/misc_helpers.lua | 43 ++ builtin/game/init.lua | 2 + builtin/ui/elem.lua | 100 +++++ builtin/ui/init.lua | 28 ++ builtin/ui/selector.lua | 241 ++++++++++++ builtin/ui/style.lua | 216 +++++++++++ builtin/ui/util.lua | 85 ++++ builtin/ui/window.lua | 241 ++++++++++++ doc/lua_api.md | 669 ++++++++++++++++++++++++++++++++ 10 files changed, 1633 insertions(+), 1 deletion(-) create mode 100644 builtin/ui/elem.lua create mode 100644 builtin/ui/init.lua create mode 100644 builtin/ui/selector.lua create mode 100644 builtin/ui/style.lua create mode 100644 builtin/ui/util.lua create mode 100644 builtin/ui/window.lua diff --git a/.luacheckrc b/.luacheckrc index 54ece656a35a..57bd96fbfc7c 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -19,7 +19,14 @@ read_globals = { "Settings", string = {fields = {"split", "trim"}}, - table = {fields = {"copy", "getn", "indexof", "insert_all"}}, + table = {fields = { + "copy", + "shallow_copy", + "getn", + "indexof", + "insert_all", + "merge" + }}, math = {fields = {"hypot", "round"}}, } diff --git a/builtin/common/misc_helpers.lua b/builtin/common/misc_helpers.lua index 90ac2ae4ef1d..b8612ac27e90 100644 --- a/builtin/common/misc_helpers.lua +++ b/builtin/common/misc_helpers.lua @@ -489,6 +489,15 @@ function table.copy(t, seen) end +function table.shallow_copy(t) + local new = {} + for k, v in pairs(t) do + new[k] = v + end + return new +end + + function table.insert_all(t, other) for i=1, #other do t[#t + 1] = other[i] @@ -497,6 +506,15 @@ function table.insert_all(t, other) end +function table.merge(...) + local new = {} + for _, t in ipairs{...} do + table.insert_all(new, t) + end + return new +end + + function table.key_value_swap(t) local ti = {} for k,v in pairs(t) do @@ -760,3 +778,28 @@ function core.parse_coordinates(x, y, z, relative_to) local rz = core.parse_relative_number(z, relative_to.z) return rx and ry and rz and { x = rx, y = ry, z = rz } end + +local function call(class, ...) + local obj = core.class(class) + if obj.new then + obj:new(...) + end + return obj +end + +function core.class(super) + super = super or {} + super.__index = super + super.__call = call + + return setmetatable({}, super) +end + +function core.is_instance(obj, class) + if type(obj) ~= "table" then + return false + end + + local meta = getmetatable(obj) + return meta == class or core.is_instance(meta, class) +end diff --git a/builtin/game/init.lua b/builtin/game/init.lua index e6a8e800b92b..46d020cd416b 100644 --- a/builtin/game/init.lua +++ b/builtin/game/init.lua @@ -2,6 +2,7 @@ local scriptpath = core.get_builtin_path() local commonpath = scriptpath .. "common" .. DIR_DELIM local gamepath = scriptpath .. "game".. DIR_DELIM +local uipath = scriptpath .. "ui" .. DIR_DELIM -- Shared between builtin files, but -- not exposed to outer context @@ -37,6 +38,7 @@ dofile(gamepath .. "forceloading.lua") dofile(gamepath .. "statbars.lua") dofile(gamepath .. "knockback.lua") dofile(gamepath .. "async.lua") +dofile(uipath .. "init.lua") core.after(0, builtin_shared.cache_content_ids) diff --git a/builtin/ui/elem.lua b/builtin/ui/elem.lua new file mode 100644 index 000000000000..b959435dce2c --- /dev/null +++ b/builtin/ui/elem.lua @@ -0,0 +1,100 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +ui._elem_types = {} + +function ui._new_type(base, type, type_id) + local class = core.class(base) + + class._type = type + class._type_id = type_id + + ui._elem_types[type] = class + + return class +end + +ui.Elem = ui._new_type(nil, "elem", 0) + +function ui.Elem:new(props) + self._id = props.id or ui.new_id() + self._groups = {} + self._boxes = {main = true} + self._style = props.style or ui.Style{} + self._children = table.merge(props.children or props) + + assert(ui.is_id(self._id), "Element ID must be an ID string") + + for _, group in ipairs(props.groups or {}) do + assert(ui.is_id(group), "Element group must be an ID string") + self._groups[group] = true + end +end + +function ui.Elem:_get_flat() + local elems = {self} + for _, child in ipairs(self._children) do + table.insert_all(elems, child:_get_flat()) + end + return elems +end + +function ui.Elem:_encode() + return ui._encode("Bz S", self._type_id, self._id, self:_encode_fields()) +end + +function ui.Elem:_encode_fields() + local fl = ui._make_flags() + + if ui._shift_flag(fl, #self._children > 0) then + local child_ids = {} + for i, child in ipairs(self._children) do + child_ids[i] = child._id + end + + ui._encode_flag(fl, "Z", ui._encode_array("z", child_ids)) + end + + self:_encode_box(fl, "main") + + return ui._encode("S", ui._encode_flags(fl)) +end + +function ui.Elem:_encode_box(fl, name) + local box = self._boxes[name] + + -- Element encoding always happens after styles are computed and boxes are + -- populated with style indices. So, if this box has any styles applied to + -- it, encode the relevant states. + if not ui._shift_flag(fl, box.n > 0) then + return + end + + local box_fl = ui._make_flags() + + -- For each state, check if there is any styling. If there is, add it + -- to the box's flags. + for i = ui._STATE_NONE, ui._NUM_STATES - 1 do + if ui._shift_flag(box_fl, box[i] ~= ui._NO_STYLE) then + ui._encode_flag(box_fl, "I", box[i]) + end + end + + ui._encode_flag(fl, "S", ui._encode_flags(box_fl)) +end diff --git a/builtin/ui/init.lua b/builtin/ui/init.lua new file mode 100644 index 000000000000..c6778772851d --- /dev/null +++ b/builtin/ui/init.lua @@ -0,0 +1,28 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +ui = {} + +local UI_PATH = core.get_builtin_path() .. "ui" .. DIR_DELIM + +dofile(UI_PATH .. "util.lua") +dofile(UI_PATH .. "selector.lua") +dofile(UI_PATH .. "style.lua") +dofile(UI_PATH .. "elem.lua") +dofile(UI_PATH .. "window.lua") diff --git a/builtin/ui/selector.lua b/builtin/ui/selector.lua new file mode 100644 index 000000000000..f19192d74818 --- /dev/null +++ b/builtin/ui/selector.lua @@ -0,0 +1,241 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +ui._STATE_NONE = 0 +ui._NUM_STATES = bit.lshift(1, 5) +ui._NO_STYLE = -1 + +local function select_elem(elem, sels) + -- Check each selector condition in turn and return nothing if any are not + -- satisfied by this element. + if sels.type and sels.type ~= elem._type then + return nil, nil + end + + if sels.id and sels.id ~= elem._id then + return nil, nil + end + + for _, group in ipairs(sels.groups) do + if not elem._groups[group] then + return nil, nil + end + end + + -- Since the element passes, check if it has the required box. + local box = elem._boxes[sels.box or "main"] + if not box then + return nil, nil + end + + -- Everything matches, so return the index of the style properties table + -- for the corresponding state. + return box, sels.states +end + +function ui._select_elems(elems, sels) + local box_states = {} + + for _, elem in ipairs(elems) do + for _, sel in ipairs(sels) do + local box, states = select_elem(elem, sel) + if box then + table.insert(box_states, {box = box, states = states}) + end + end + end + + return box_states +end + +local function parse_type(has_univ, type_str) + if type_str ~= "" then + assert(has_univ == "", "Extra characters after '*'") + assert(ui._elem_types[type_str], "Invalid type name: " .. type_str) + return type_str + end + return nil +end + +local function parse_id(has_id, id_str) + if has_id == "#" then + assert(id_str ~= "", "Missing characters after '#'") + return id_str + end + + -- This should be impossible because, since there was no '#', the type + -- selector should have been greedy and eaten all the ID characters. + assert(id_str == "") + return nil +end + +local function parse_box(has_box, box_str) + if has_box == "@" then + assert(box_str ~= "", "Missing characters after '@'") + return box_str + end + + -- Selectors implicitly default to "main" if no box is specified, but we + -- leave it as nil because a cascaded selector may have an explicit box, + -- which should take precedence over the implicit "main". + assert(box_str == "") + return nil +end + +local function parse_groups(groups_str) + local groups = groups_str:split(".", true) + + -- The first split is not started by a '.' and follows a greedy match by + -- the type or ID. So, remove it. + assert(groups[1] == "") + table.remove(groups, 1) + + for _, group in ipairs(groups) do + assert(group ~= "", "Missing characters after '.'") + end + return groups +end + +local states_by_name = { + focused = bit.lshift(1, 0), + selected = bit.lshift(1, 1), + hovered = bit.lshift(1, 2), + pressed = bit.lshift(1, 3), + disabled = bit.lshift(1, 4), +} + +local function parse_states(states_str) + local states = states_str:split("$", true) + + -- Like the first split in the groups, the first state never has a value. + assert(states[1] == "") + table.remove(states, 1) + + -- Convert the states into their bitwise representation. + local flags = ui._STATE_NONE + + for _, name in ipairs(states) do + -- Give a nicer error for empty states. + assert(name ~= "", "Missing characters after '$'") + + -- Get the state mask. If the name was valid, OR it into the full set + -- of state flags. + local state = states_by_name[name] + assert(state, "Invalid state name: " .. name) + + flags = bit.bor(flags, state) + end + + return flags +end + +local SEL_PATTERN = + "^" .. + "(%*?)" .. -- Universal selector + "([" .. ui._ID_CHARS .. "]*)" .. -- Type selector + "(%#?)" .. -- ID selector + "([" .. ui._ID_CHARS .. "]*)" .. + "([%." .. ui._ID_CHARS .. "]*)" .. -- Group selectors + "(%@?)" .. -- Box selector + "([" .. ui._ID_CHARS .. "]*)" .. + "([%$" .. ui._ID_CHARS .. "]*)" .. -- State selectors + "$" + +local function parse_one_sel(str) + -- SEL_PATTERN accepts the empty string, so explicitly disallow it here. + assert(str ~= "", "Empty style selector") + + -- Since universal selectors are common (as a blank ui.Style selector + -- defaults to a universal selector, and those are used all over the + -- place), return it immediately and skip all the parsing. + if str == "*" then + return { + type = nil, + id = nil, + box = nil, + groups = {}, + states = ui._STATE_NONE, + } + end + + -- Parse the style selector, erroring if the string could not be parsed. + local has_univ, type_str, has_id, id_str, + groups_str, has_box, box_str, states_str = str:match(SEL_PATTERN) + assert(has_univ, "Invalid style selector syntax") + + -- Parse each section, and return our parsed selector. + return { + type = parse_type(has_univ, type_str), + id = parse_id(has_id, id_str), + box = parse_box(has_box, box_str), + groups = parse_groups(groups_str), + states = parse_states(states_str), + } +end + +function ui._parse_sels(sel_str) + assert(type(sel_str) == "string", "Selector must be string") + local strs = sel_str:split(",", true) + local sels = {} + + for i, str in ipairs(strs) do + sels[i] = parse_one_sel(str:trim()) + end + + return sels +end + +local function check_conflict(parent_sel, child_sel, field) + if parent_sel[field] and child_sel[field] then + assert(parent_sel[field] == child_sel[field], + "Cascaded selectors have conflicting " .. field .. " selectors: " .. + parent_sel[field] .. " and " .. child_sel[field]) + end +end + +local function cascade_one_sel(parent_sel, child_sel) + -- Different type, ID, and box selectors are mutually exclusive when + -- cascaded since an element cannot simultaneously have two different + -- types, for instance, so error if any of these conflict. + check_conflict(parent_sel, child_sel, "type") + check_conflict(parent_sel, child_sel, "id") + check_conflict(parent_sel, child_sel, "box") + + return { + type = parent_sel.type or child_sel.type, + id = parent_sel.id or child_sel.id, + box = parent_sel.box or child_sel.box, + groups = table.merge(parent_sel.groups, child_sel.groups), + states = bit.bor(parent_sel.states, child_sel.states), + } +end + +function ui._cascade_sels(parent_sels, child_sels) + local all_sels = {} + + -- Take the Cartesian product of the parent and child selector lists and + -- cascade each pair into a new selector. + for _, parent_sel in ipairs(parent_sels) do + for _, child_sel in ipairs(child_sels) do + table.insert(all_sels, cascade_one_sel(parent_sel, child_sel)) + end + end + + return all_sels +end diff --git a/builtin/ui/style.lua b/builtin/ui/style.lua new file mode 100644 index 000000000000..b0c95a49a6f9 --- /dev/null +++ b/builtin/ui/style.lua @@ -0,0 +1,216 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +ui.Style = core.class() + +function ui.Style:new(props) + self._sels = ui._parse_sels(props.sel or "*") + self._reset = props.reset + self._props = ui._cascade_props(props.props or props, {}) + self._sub_styles = table.merge(props.sub_styles or props) +end + +function ui.Style:_get_flat() + local flat_styles = {} + self:_get_flat_impl(flat_styles, ui._parse_sels("*")) + return flat_styles +end + +function ui.Style:_get_flat_impl(flat_styles, parent_sels) + -- Cascade our selector with all our cascaded parent selectors, resulting + -- in a fully qualified selector. + local full_sels = ui._cascade_sels(parent_sels, self._sels) + + -- Copy this style's properties into a new style with the full selector. + local flat = ui.Style{ + reset = self._reset, + props = self._props, + } + flat._sels = full_sels + + table.insert(flat_styles, flat) + + -- For each sub-style of this style, cascade it with our full selector and + -- add it to the list of flat elements. + for _, sub_style in ipairs(self._sub_styles) do + sub_style:_get_flat_impl(flat_styles, full_sels) + end +end + +function ui._cascade_props(add, props) + local new = {} + + new.pos = add.pos or props.pos + new.size = add.size or props.size + new.anchor = add.anchor or props.anchor + + new.apos = add.apos or props.apos + new.asize = add.asize or props.asize + + new.margin = add.margin or props.margin + new.padding = add.padding or props.padding + + new.bg_image = add.bg_image or props.bg_image + new.bg_fill = add.bg_fill or props.bg_fill + new.bg_tint = add.bg_tint or props.bg_tint + + new.bg_source = add.bg_source or props.bg_source + new.bg_middle = add.bg_middle or props.bg_middle + + new.bg_frames = add.bg_frames or props.bg_frames + new.bg_frame_time = add.bg_frame_time or props.bg_frame_time + new.bg_frame_offset = add.bg_frame_offset or props.bg_frame_offset + + new.fg_image = add.fg_image or props.fg_image + new.fg_fill = add.fg_fill or props.fg_fill + new.fg_tint = add.fg_tint or props.fg_tint + + new.fg_source = add.fg_source or props.fg_source + new.fg_middle = add.fg_middle or props.fg_middle + + new.fg_frames = add.fg_frames or props.fg_frames + new.fg_frame_time = add.fg_frame_time or props.fg_frame_time + new.fg_frame_offset = add.fg_frame_offset or props.fg_frame_offset + + new.fg_scale = add.fg_scale or props.fg_scale + new.fg_halign = add.fg_halign or props.fg_halign + new.fg_valign = add.fg_valign or props.fg_valign + + new.visible = ui._apply_bool(add.visible, props.visible) + new.noclip = ui._apply_bool(add.noclip, props.noclip) + + return new +end + +local function encode_vec(vec, fall) + return ui._encode("ff", vec[1], vec[2]) +end + +local function encode_rect(rect) + return ui._encode("ffff", rect[1], rect[2], rect[3], rect[4]) +end + +local function encode_halign(halign) + local map = {left = 0, center = 1, right = 2} + return ui._encode("B", map[halign]) +end + +local function encode_valign(valign) + local map = {top = 0, center = 1, bottom = 2} + return ui._encode("B", map[valign]) +end + +local function encode_layer(props, p) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props[p.."_image"]) then + ui._encode_flag(fl, "z", props[p.."_image"]) + end + if ui._shift_flag(fl, props[p.."_fill"]) then + ui._encode_flag(fl, "I", core.colorspec_to_colorint(props[p.."_fill"])) + end + if ui._shift_flag(fl, props[p.."_tint"]) then + ui._encode_flag(fl, "I", core.colorspec_to_colorint(props[p.."_tint"])) + end + + if ui._shift_flag(fl, props[p.."_source"]) then + ui._encode_flag(fl, "Z", encode_rect(props[p.."_source"])) + end + if ui._shift_flag(fl, props[p.."_middle"]) then + ui._encode_flag(fl, "Z", encode_rect(props[p.."_middle"])) + end + + if ui._shift_flag(fl, props[p.."_frames"]) then + ui._encode_flag(fl, "I", props[p.."_frames"]) + end + if ui._shift_flag(fl, props[p.."_frame_time"]) then + ui._encode_flag(fl, "I", props[p.."_frame_time"]) + end + if ui._shift_flag(fl, props[p.."_frame_offset"]) then + ui._encode_flag(fl, "Z", encode_vec(props[p.."_frame_offset"])) + end + + return fl +end + +function ui._encode_props(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.pos) then + ui._encode_flag(fl, "Z", encode_vec(props.pos)) + end + if ui._shift_flag(fl, props.size) then + ui._encode_flag(fl, "Z", encode_vec(props.size)) + end + if ui._shift_flag(fl, props.anchor) then + ui._encode_flag(fl, "Z", encode_vec(props.anchor)) + end + + if ui._shift_flag(fl, props.apos) then + ui._encode_flag(fl, "Z", encode_vec(props.apos)) + end + if ui._shift_flag(fl, props.asize) then + ui._encode_flag(fl, "Z", encode_vec(props.asize)) + end + + if ui._shift_flag(fl, props.margin) then + ui._encode_flag(fl, "Z", encode_rect(props.margin)) + end + if ui._shift_flag(fl, props.padding) then + ui._encode_flag(fl, "Z", encode_rect(props.padding)) + end + + local bg_fl = encode_layer(props, "bg") + if ui._shift_flag(fl, bg_fl.flags ~= 0) then + ui._encode_flag(fl, "S", ui._encode_flags(bg_fl)) + end + local fg_fl = encode_layer(props, "fg") + if ui._shift_flag(fl, fg_fl.flags ~= 0) then + ui._encode_flag(fl, "S", ui._encode_flags(fg_fl)) + end + + if ui._shift_flag(fl, props.fg_scale) then + ui._encode_flag(fl, "f", props.fg_scale) + end + if ui._shift_flag(fl, props.fg_halign) then + ui._encode_flag(fl, "Z", encode_halign(props.fg_halign)) + end + if ui._shift_flag(fl, props.fg_valign) then + ui._encode_flag(fl, "Z", encode_valign(props.fg_valign)) + end + + if ui._shift_flag(fl, props.visible ~= nil) then + ui._shift_flag(fl, props.visible) + end + if ui._shift_flag(fl, props.noclip ~= nil) then + ui._shift_flag(fl, props.noclip) + end + + return ui._encode("S", ui._encode_flags(fl)) +end + +local default_theme = ui.Style{} + +function ui.get_default_theme() + return default_theme +end + +function ui.set_default_theme(theme) + default_theme = theme +end diff --git a/builtin/ui/util.lua b/builtin/ui/util.lua new file mode 100644 index 000000000000..ab91b1f7cbb7 --- /dev/null +++ b/builtin/ui/util.lua @@ -0,0 +1,85 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +local next_id = 0 + +function ui.new_id() + -- Just increment a monotonic counter and return it as hex. Even at + -- unreasonably fast ID generation rates, it would take years for this + -- counter to hit the 2^53 limit and start generating duplicates. + next_id = next_id + 1 + return string.format("_%X", next_id) +end + +ui._ID_CHARS = "a-zA-Z0-9_%-%:" + +function ui.is_id(str) + return type(str) == "string" and str == str:match("^[" .. ui._ID_CHARS .. "]+$") +end + +function ui._apply_bool(add, prop) + if add ~= nil then + return add + end + return prop +end + +ui._encode = core.encode_network +ui._decode = core.decode_network + +function ui._encode_array(format, arr) + local formatted = {} + for _, val in ipairs(arr) do + table.insert(formatted, ui._encode(format, val)) + end + + return ui._encode("IZ", #formatted, table.concat(formatted)) +end + +function ui._pack_flags(...) + local flags = 0 + for _, flag in ipairs({...}) do + flags = bit.bor(bit.lshift(flags, 1), flag and 1 or 0) + end + return flags +end + +function ui._make_flags() + return {flags = 0, num_flags = 0, data = {}} +end + +function ui._shift_flag(fl, flag) + -- OR the LSB with the condition, and then right rotate it to the MSB. + fl.flags = bit.ror(bit.bor(fl.flags, flag and 1 or 0), 1) + fl.num_flags = fl.num_flags + 1 + + return flag +end + +function ui._encode_flag(fl, ...) + table.insert(fl.data, ui._encode(...)) +end + +function ui._encode_flags(fl) + -- We've been shifting into the right the entire time, so flags are in the + -- upper bits; however, the protocol expects them to be in the lower bits. + -- So, shift them the appropriate amount into the lower bits. + local adjusted = bit.rshift(fl.flags, 32 - fl.num_flags) + return ui._encode("I", adjusted) .. table.concat(fl.data) +end diff --git a/builtin/ui/window.lua b/builtin/ui/window.lua new file mode 100644 index 000000000000..e36ab734d9dd --- /dev/null +++ b/builtin/ui/window.lua @@ -0,0 +1,241 @@ +--[[ +Minetest +Copyright (C) 2023 v-rob, Vincent Robinson + +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, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +--]] + +ui.Window = core.class() + +local window_types = { + bg = 0, + mask = 1, + hud = 2, + message = 3, + gui = 4, + fg = 5, +} + +function ui.Window:new(props) + self._type = props.type + self._uncloseable = props.uncloseable + + self._theme = props.theme or ui.get_default_theme() + self._style = props.style or ui.Style{} + self._root = props.root + + assert(window_types[self._type], "Invalid window type") + assert(minetest.is_instance(self._root, ui.Elem, + "Expected root of window to be an element")) +end + +function ui.Window:_encode(player) + local elems = self._root:_get_flat() + + local enc_styles = self:_encode_styles(elems) + local enc_elems = self:_encode_elems(elems) + + return ui._encode("B ZzZ", window_types[self._type], + enc_elems, self._root._id, enc_styles) +end + +local function apply_styles(elems, styles) + for _, style in ipairs(styles) do + -- Get every box and state that this style's selector matches. + local box_states = ui._select_elems(elems, style._sels) + + for _, pair in pairs(box_states) do + -- Get the existing style property table for this box if it exists. + local props = pair.box[pair.states] or {} + + -- If this style resets all properties, clear the property table. + if style._reset then + props = {} + end + + -- Cascade the properties from this style onto the box. + pair.box[pair.states] = ui._cascade_props(style._props, props) + end + end +end + +local function index_style(box, i, style_indices, enc_styles) + -- If we have a style for this state, serialize it to a string. Identical + -- styles have identical strings, so we use this to our advantage. + local enc = ui._encode_props(box[i]) + + -- If we haven't serialized a style identical to this one before, store + -- this as the latest index in the list of style strings. + if not style_indices[enc] then + style_indices[enc] = #enc_styles + table.insert(enc_styles, enc) + end + + -- Set the index of our state to the index of its style string, and keep + -- count of how many states with valid indices we have for this box so far. + box[i] = style_indices[enc] + box.n = box.n + 1 +end + +local function index_styles(elems) + local style_indices = {} + local enc_styles = {} + + for _, elem in ipairs(elems) do + for _, box in pairs(elem._boxes) do + for i = ui._STATE_NONE, ui._NUM_STATES - 1 do + if box[i] then + -- If this box has a style, encode and index it. + index_style(box, i, style_indices, enc_styles) + else + -- Otherwise, this state has no style, so set it as such. + box[i] = ui._NO_STYLE + end + end + end + end + + return enc_styles +end + +function ui.Window:_encode_styles(elems) + -- Clear out all the boxes in every element. + for _, elem in ipairs(elems) do + for box in pairs(elem._boxes) do + elem._boxes[box] = {n = 0} + end + end + + -- Get a cascaded and flattened list of all the styles for this window. + local styles = self:_get_full_style(elems):_get_flat() + + -- Take each style and apply its properties to every box and state matched + -- by its selector. + apply_styles(elems, styles) + + -- Take the styled boxes and encode their styles into a single table, + -- replacing the boxes' style property tables with indices into this table. + local enc_styles = index_styles(elems) + + return ui._encode_array("Z", enc_styles) +end + +function ui.Window:_get_full_style(elems) + -- The full style contains the theme, global style, and inline element + -- styles as sub-styles, in that order, to ensure the correct precedence. + local styles = {self._theme, self._style} + + for _, elem in ipairs(elems) do + -- Cascade the inline style with the element's ID, ensuring that the + -- inline style globally refers to this element only. + table.insert(styles, ui.Style{ + sel = "#" .. elem._id, + sub_styles = {elem._style}, + }) + end + + -- Return all these styles wrapped up into a single style. + return ui.Style{ + sub_styles = styles, + } +end + +function ui.Window:_encode_elems(elems) + local enc_elems = {} + + for _, elem in ipairs(elems) do + table.insert(enc_elems, elem:_encode()) + end + + return ui._encode_array("Z", enc_elems) +end + +local open_windows = {} + +local function build_window(id) + local data = open_windows[id] + assert(data, "No window with id \"" .. id .. "\"") + + local window = data.builder(id, data.player, data.context) + assert(core.is_instance(window, ui.Window), + "Expected window to be returned from builder function") + + return window, data.player +end + +local OPEN_WINDOW = 0 +local REOPEN_WINDOW = 1 +local UPDATE_WINDOW = 2 +local CLOSE_WINDOW = 3 + +local last_id = 0 + +function ui.open(builder, player, context) + local id = last_id + last_id = last_id + 1 + + open_windows[id] = { + builder = builder, + player = player, + context = context or {}, + } + + local window = build_window(id) + + local open_flags = ui._pack_flags(window._uncloseable) + local data = ui._encode("BL ZB", OPEN_WINDOW, id, + window:_encode(player), open_flags) + + core.show_formspec(player, "__ui__", data) + return id +end + +function ui.reopen(close_id) + local new_id = last_id + last_id = last_id + 1 + + open_windows[new_id] = open_windows[close_id] + open_windows[close_id] = nil + + local window, player = build_window(new_id) + + local open_flags = ui._pack_flags(window._uncloseable) + local data = ui._encode("BLL ZB", REOPEN_WINDOW, new_id, close_id, + window:_encode(player), open_flags) + + core.show_formspec(player, "__ui__", data) + return new_id +end + +function ui.update(id) + local window, player = build_window(id) + + local data = ui._encode("BL Z", UPDATE_WINDOW, id, + window:_encode(player)) + + core.show_formspec(player, "__ui__", data) +end + +function ui.close(id) + local player = open_windows[id].player + local data = ui._encode("BL", CLOSE_WINDOW, id) + + core.show_formspec(player, "__ui__", data) + open_windows[id] = nil +end + +function ui.window_info(id) + return table.shallow_copy(open_windows[id]) +end diff --git a/doc/lua_api.md b/doc/lua_api.md index 3a0f1c0066bf..846b65c8ede4 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -2562,6 +2562,638 @@ print(ItemStack("mod:item_with_no_desc"):get_description()) --> mod:item_with_no ``` +UI API +====== + +**Warning**: The UI API is entirely experimental and may only be used for +testing purposes, _not_ for stable mods. The API can and will change without +warning between versions until it is feature complete and stabilized, including +the network protocol. **USE AT YOUR OWN RISK!** + +The UI API is the in-progress replacement for the older formspec and player HUD +systems, exposing a new system that is simpler, more robust and powerful, and +less buggy and quirky than its predecessors. It is not yet stable, and feedback +is encouraged. + +The API is exposed to Lua through the global `ui` namespace of functions and +classes. Most of these classes are opaque and effectively immutable, meaning +they have no user-visible properties or methods. Users must not access or +change undocumented properties or inherit from any UI class. + +Basic information +----------------- + +### ID strings + +Elements require unique IDs, which are represented as strings. ID strings may +only contain letters, numbers, dashes, underscores, and colons, and may not be +the empty string. + +All IDs starting with a dash, underscore, or colon are reserved for use by the +engine, and should not be directly used unless the documentation explicitly +allows for doing so. + +Element and group IDs are local to a single window, so the `mod_name:` prefix +used elsewhere in Minetest is generally unnecessary for them. However, if a +separate mod creates elements or styles as a library for other mods, using a +mod prefix for the created element and group IDs is highly encouraged to avoid +conflicts. Colons should not be used for any other purpose. + +### Positioning + +Unlike the unnecessarily complex coordinate system of formspecs, the UI API +specifies positions and sizes in scaled pixels as defined by +`minetest.get_player_window_information()`, i.e. each pixel is either +`real_gui_scaling` or `real_hud_scaling` physical pixels in size, depending on +whether the UI window is a GUI or a HUD. + +These scaled pixels apply to everything in the UI, including things that are +already naturally pixel-based like paddings, margins, and nine-slice image +rectangles. This allows players to specify scaling that is adjusted to their +screen and/or accessibility needs, but without sacrificing the flexibility +afforded by pixel-perfect UIs. + +However, since positioning everything using pixels is inflexible and tedious +for UIs of any substantial size, the UI API also provides sizers for automatic +layout of elements. + +**NOTE**: Sizers are not yet implemented. + +### Constructors + +Constructors to various UI APIs take a property table for simple initialization +of values of the element. Sometimes, as a convenience DSL-like syntax, certain +properties may be "inlined" into the initialization table rather than specified +as alternate sub-properties. For instance, these create the same element tree: + +```lua +ui.Elem{ + id = "parent", + + -- `children` property is specified; the elements in the given list are + -- used for the child elements. + children = { + ui.Elem{id = "child1"}, + ui.Elem{id = "child2"}, + }, + + -- All elements outside of the `children` property are ignored. + ui.Elem{id = "ignored"}, +} + +ui.Elem{ + id = "parent", + + -- `children` property is omitted; numeric constructor table indices are + -- used as the child elements. + ui.Elem{id = "child1"}, + ui.Elem{id = "child2"}, +} +``` + +All tables passed into constructor functions are defensively copied by the API. +Modifying a table after passing it in to a constructor will not change the +constructed object in any way. + +Unless otherwise documented, it should be assumed that all constructor fields +are optional. + +Display functions +----------------- + +In order to display a UI, a builder function must be provided. When called, +this function should return a window containing the desired element tree. Every +time the window is shown to a player or updated, the builder function is called +again. This allows the function to change the number of elements or the +properties they have. Since window and element objects are immutable to the +user, rebuilding everything using the builder function is the only way to +modify the UI. + +Since windows created by a single builder function can be shown to multiple +players at a time, or even multiple times to the same player, UIs can keep +their state in a context table. When a UI is shown to a player with +`ui.open()`, a context table can be provided that holds state that will be +passed to the builder function every time it is called for this UI. This table +can be modified by the builder function or by event handlers in the UI. The UI +API will never modify a context table itself. + +To show a UI to a player, pass the builder function and player name to +`ui.open()`, which will return a unique window ID number for the UI that was +just opened. If state has changed and the UI needs to be rebuilt and shown to +the player, the `ui.update()` function can be called using the window ID. To +close the window programmatically, use `ui.close()`. After closing the window, +the closed window can no longer be used, and `ui.open()` must be used to show +the window again. + +Some properties cannot be updated via `ui.update()` since that could lead to +race conditions where the server changed a property but the client sent an +event that relied on the old property before it received the server's changes. +For most events, this is not problematic, but this can cause major problems for +others, such as uncloseable windows. To update these properties, use the +`ui.reopen()` function, which will effectively call `ui.close()` followed by +`ui.open()` and return the new ID, but only using a single message and +preserving the context table. Note that all transient properties will be set +with this operation, so it's not seamless to the users like `ui.update()` is. + +To get the context, player, or builder function for a window ID, use the +`ui.window_info()` function. + +A basic example for opening and updating a UI looks like this: + +```lua +local function builder(id, player, cx) + return ui.Window{ + type = "gui", + + -- **NOTE**: Buttons are not yet implemented. + root = ui.Button{ + style = ui.Style{ + anchor = {0.5, 0.5}, + pos = {0.5, 0.5}, + size = {0, 0}, + asize = {200, 100}, + }, + + -- **NOTE**: Events and text are not yet implemented. + text = "Clicks: " .. cx.num_clicks, + + on_click = function(ev) + cx.num_clicks = cx.num_clicks + 1 + ui.update(id) + end, + }, + } +end + +core.register_on_joinplayer(function(player) + local id = ui.open(builder, player:get_player_name(), { + num_clicks = 0, + }) +end) +``` + +### Functions + +* `ui.open(builder, player[, context])`: Builds a new window with an initial + context and shows it to a player. + * `builder`: The builder function for the window. + * `player`: The player to show the window to. + * `context` (optional): The initial context for the window. If not + provided, defaults to an empty table. + * Returns the window ID for the newly shown window. +* `ui.update(id)`: Updates an existing window by rebuilding it and propagating + the changes to the player. + * `id`: The window ID of the window to update. +* `ui.reshow(id)`: Reshows a window by rebuilding it, closing the player's old + window, and showing the new window to the player immediately. + * `id`: The window ID of the window to reshow. + * Returns the new window ID for the new window. The old window ID is no + longer valid. +* `ui.close(id)`: Closes a window that is currently shown to the player. + * `id`: The window ID of the window to close. This ID is no longer valid + after this call returns. +* `ui.window_info(id)`: Returns a table containing information about a + currently open window. + * `id`: The window ID of the window to get information about. + * Returns a table with the following properties: + * `builder`: The builder function for this window. + * `player`: The player that the window is shown to. + * `context`: The context table for this window. + +Utility functions +----------------- + +* `ui.new_id()`: Returns a new globally unique ID string. + - The format of this ID is not specified, but it will have the format of an + engine reserved ID and will not conflict with any other ID created during + this session. +* `ui.is_id(str)`: Checks whether the argument is a string that follows the + format of an ID string. +* `ui.get_default_theme()`: Returns the style used as the default theme when no + explicit theme is provided for a window. +* `ui.set_default_theme(theme)`: Sets the default theme to a new style, + overriding the default engine-provided theme. + +`ui.Window` +----------- + +Windows represent discrete self-contained UIs formed by a tree of elements and +other parameters affecting the entire window. + +The window contains a single element, which is the root element of the element +tree. Since the root element has no parent, it is positioned relative to the +entire screen. + +### Window types + +Windows have a window type, which determines whether they can receive user +input, what type of scaling to apply to the pixels, and the Z order of how they +are drawn in relation to other things on the screen. These are the following: + +* `bg`: Used for things that need to be drawn before everything else. +* `mask`: Used for visual effects covering the player's face, such as masks. +* `hud`: Used for normal HUD purposes. Hidden when the HUD is hidden. +* `message`: Used to display GUI-like popup messages, but that can't be + interacted with. Hidden when the chat is hidden. +* `gui`: Used for GUIs that the user can interact with. If there are no + formspecs open but one or more `gui` windows are open, then the last opened + window receives user input. +* `fg`: Used for things that need to be drawn after everything else. + +Only the `gui` layer can receive user input from the mouse or keyboard. No +other layer will receive events. The `gui` and `message` window types use +`gui_scaling` for the base pixel size, whereas every other type uses +`hud_scaling`. + +**NOTE**: Until events are implemented, the `gui` layer cannot receive user +input and is effectively a HUD layer. + +The Z order for window types and other things displayed on the screen is: + +* Minetest world +* `bg` window types +* Wieldhand and crosshairs +* `mask` window types +* Hotbar and player HUD API elements +* `hud` window types +* Nametags and minimap +* `message` window types +* `gui` window types +* Formspecs +* `fg` window types + +### Styling + +There are two properties in the window relevant to styling: `theme` and +`style`. Both properties use a `ui.Style` object to select elements from the +entire element tree and apply styles to them. + +The `theme` property is meant for using an externally provided theme that gives +default styling to different elements. If one is not specified explicitly, the +default theme from `ui.get_default_theme()` will be used instead. +Alternatively, to have a UI with no default theming, an empty `ui.Style` can be +used instead. Note that most elements are unusable without theming; for +instance, all the boxes in a scrollbar element will be transparent, overlap, +and fill the entire element area. + +The `style` property is intended for window-specific styling. It takes higher +precedence than the `theme` property, meaning that properties set by the style +will override properties set by the theme. + +### Fields + +The following fields can be provided to the `ui.Window` constructor: + +* `type` (required): The window type for this window. +* `root` (required): A `ui.Elem` that is the root element for the element tree. +* `uncloseable`: Indicates whether the user is able to close the window via the + ESC key or similar. Defaults to false. **NOTE**: Not yet implemented. +* `theme`: Specifies a style to use as the window's theme. Defaults to the + theme provided by `ui.get_default_theme()`. +* `style`: Specifies a style to apply across the entire element tree. Defaults + to an empty style. + +`ui.Elem` +--------- + +Elements are the basic units of interface in the UI API and include such things +as buttons and input fields. All elements inherit from the `ui.Elem` class. + +Each element has a list of child elements. Child elements are positioned +inside their parent element, and are thus subject to any special positioning +rules that a specific element has, such as scrolled elements. + +### Element IDs + +Each element in a window is required to have a unique ID that is different from +every other ID in that window. This ID uniquely identifies the element for +both network communication and styling. Elements that have user interaction +require an ID to be provided whereas static elements will automatically +generate an ID if none is provided. Each element's [Type info] section lists +whether IDs must be provided. + +Note that static elements with automatically generated IDs may still have boxes +that are styled according to their `pressed` state. If the window is updated +while the `pressed` state is active, the box will stop being pressed. Depending +on the circumstances, it may be advantageous to provide fixed IDs for elements +with `pressed` state styling. This does not apply to the `focused` and +`selected` states because they are never active on static elements. + +### Transient fields + +Some elements have properties that can be modified by the user, such as input +fields. However, it must also be possible for the server to modify them. Since +there may be substantial latency between client and server, it is undesirable +for the server to update every such field every time the window is updated, as +is the case with formspecs, since that may overwrite the user's input. + +So, fields that can be modified by the user are known as "transient fields". +When a new element is created, which includes all elements when a window is +opened or reopened, the client sets all fields for that element, transient or +not. When updating an existing element, however, the values that the server +sends for the transient fields will not automatically override the values on +the client, so the client will keeps its current value for the transient field. + +If the server does need to update a transient field, it can do so by opting in +with a flag to set the field. For instance, if the `text` field is transient, +then to update the value of the `text` field, the server needs to set the +`update_text` flag to true. If `update_text` is not set, then the `text` field +will be ignored by the client, regardless of whether it has a value. + +Element IDs cannot be automatically generated for elements with transient +fields because automatically generated IDs change every time the UI is rebuilt, +which causes the old element to be destroyed on the client and the transient +data to be lost. + +**NOTE**: Transient fields are not implemented in any current elements. + +### Styling + +Each element has a specific type name that is used when referring to the +element in a `SelectorSpec`. The type name of each element is listed in the +element's [Type info] section. + +Elements can be styled according to their unique ID. Additionally, elements +also have a list of non-unique group IDs that allow selectors to style multiple +elements at once. + +Aside from the global style found in the window, each element may have a local +style of its own that only applies to itself. Effectively, this style is the +same as appending a sub-style to the window's global style with a selector of +this element's ID; however, a local style may be more convenient in some +scenarios. Local styles have higher precedence than styles specified in the +window. + +### Boxes + +Elements contain boxes, which are rectangular regions relative to the element's +boundaries that serve as targets of styling. Boxes can be styled with any +styling properties, e.g. background images or padding. Boxes also contain +certain types of state information relevant to styling, such as whether the +mouse was pressed down within the box's boundaries. + +Boxes are referred to by name. For instance, scrollbars have a `thumb` box +representing the scrollbar thumb. Elements themselves are technically never +styled, only one of their boxes. Every element has a `main` box that is styled +by default if no other box is specifically selected. + +Different elements have different rules governing the layout of their children. +Usually, children are just positioned relative to the bounding box of one of +the element's boxes, often `main` unless there is a more specific box designed +for the purpose. + +### Type info + +* Type name: `elem` +* ID required: No +* Boxes: + * `main`: The primary box of every element, and the one that all other + boxes in the element (if any) are ultimately positioned relative to. It + is positioned relative to the bounding box of the element. + +### Fields + +The following fields can be provided to the `ui.Elem` constructor: + +* `id` (possibly required): The unique ID for this element. This field is + only required if stated as such in the element's [Type info] section. +* `groups`: The list of group IDs for this element. +* `style`: A local style that only applies to this element. +* `children`: The list of elements that are children of this element. If + omitted, children are inlined into the constructor table. + +`ui.Style` +---------- + +Styles are the interface through which the display and layout characteristics +of element boxes are changed. They offer a wide variety of styling properties +that are universally supported by every element box, with full support for +styles that change based on the state of the box being styled. + +There are three components of a style: + +1. A `SelectorSpec` that decides which boxes and states to apply the style to. +2. A `StyleProps` that contains the actual styling property values. +3. A list of sub-styles with their own selectors and properties that are + cascaded with the ones in the parent style. + +### States + +The list of states is as follows, from highest precedence to lowest: + +* `disabled`: The box is disabled, which means that user interaction with it + does nothing. +* `pressed`: The left mouse button was pressed down inside the boundaries of + the box, and has not yet been released. +* `hovered`: The mouse cursor is currently inside the boundaries of the box. +* `selected`: The box is currently selected, e.g. a checkbox is checked. +* `focused`: The box currently has keyboard focus. + +States fully cascade over each other. For instance, if there are styles to give +hovered buttons yellow text and pressed buttons blue backgrounds, then a +hovered and pressed button will have yellow text on a blue background. + +However, if an element is currently in multiple states, then states with higher +precedence will override properties from lower precedences. For instance, if +one style makes hovered buttons red and another makes pressed buttons blue, +then a button that is simultaneously hovered and pressed will be blue. + +Lastly, state precedences combine. A style for pressed and hovered buttons will +override styles for only pressed or only hovered buttons. The highest state +will always win, however: a style for disabled buttons will override a style +with every other state combined. + +### Sub-styles + +Sub-styles are a way of taking a base style that applies some properties to +certain boxes and adding more styles on top of it that apply extra properties +to a subset of those boxes. + +When a style has sub-styles, the base style first applies its properties to +each of the boxes matched by its selector. Then, recursively for each sub-style +in order, the sub-selector is intersected with the parent style's selector, and +the sub-properties are applied to the boxes selected by that. + +Therefore, according to these sub-style application rules, sub-styles override +base styles, and later sub-styles override earlier sub-styles. This +order-dependent styling is in direct contrast to CSS, which calculates +precedence by selector weighting. Order-dependence was deliberately chosen +because it gives mods more control over the style of their own windows without +external themes causing problems. + +There is no reason why a parent style containing sub-styles must have a +selector or properties; sub-styles can just be used for organizational purposes +by placing related styles in an empty parent style. + +### Fields + +The following fields can be provided to the `ui.Style` constructor: + +* `sel`: The `SelectorSpec` that this style applies to. If omitted, the + selector defaults to `"*"`. +* `props`: The `StyleProps` of properties applied by this style. If omitted, + style properties are inlined into the constructor table. +* `sub_styles`: The list of `ui.Style`s that should be used as the cascading + sub-styles of this style. If omitted, sub-styles are inlined into the + constructor table. + +`SelectorSpec` +-------------- + +A selector is a string similar to a CSS selector that matches elements by type, +ID, group, box, and/or state. No actual compatibility with CSS exists because +of domain-specific problems (e.g. IDs allow colons in Minetest by convention +whereas they indicate states or pseudo-elements in CSS). + +The syntax of a selector is a comma-separated list of individual selectors, +which may each be surrounded by optional whitespace. Each sub-selector contains +multiple sections, each of which is optional. However, sections must be placed +in the correct order, and at least one of the sections must be included. The +sections in order are as follows: + +* At most one `type`, which is either: + * The string `*`, which selects any element type. + * An ID string that selects a specific element type, e.g. `button` only + selects `ui.Button` elements. Type names are not inherited, so `elem` + will match `ui.Elem` but not `ui.Button`. +* At most one `#id`, where `id` is an ID string that matches the element with + that ID. +* Any number of `.group`, where `group` is an ID string that matches any + element with that group. +* At most one `@box`, where `box` is an ID string that matches boxes in any + element that contain a box by that name. +* Any number of `$state`, where `state` is a string that matches any box + currently in that state. + +Some examples of valid selector strings and their meanings: + +* `*`: Selects all elements. +* `button.fancy.big`: Selects buttons that have both `fancy` and `big` groups. +* `scrollbar#primary@thumb`: Selects the `thumb` box of the scrollbar that has + the ID `primary`. +* `button$hovered, button$pressed, button$hovered$focused`: Selects all buttons + that are either hovered, pressed, or both hovered and focused. +* `@track$hovered`: Selects any hovered box named `track` in any element. +* `.big, *.bigger, #large-button`: Selects all elements that have the group + `big` or `bigger` or have the ID `large-button`. + +Selectors may also be cascaded with each other in the context of sub-styles. +Cascaded selectors effectively intersect all the sub-selectors together into a +single selector, increasing the specificity of the selector. Importantly, +sub-selectors in the cascaded selector cannot be contradictory, as this causes +the selector to be inconsistent and select nothing. + +Examples of both valid and invalid selector cascading combinations: + +* `scrollbar@thumb` and `$hovered` produces `scrollbar@thumb$hovered`. +* `button, scrollbar` and `.big` produces `button.big, scrollbar.big`. +* `.big$hovered` and `.bigger$pressed` produces `.big.bigger$hovered$pressed`. +* `#primary, #secondary` and `.big, *.bigger` produces + `#primary.big, #primary.bigger, #secondary.big, #secondary.bigger`. +* `#primary` and `#secondary` is invalid because elements cannot have multiple + IDs simultaneously. +* `@main$hovered` and `@thumb$pressed` is invalid because a box cannot be both + a main box and a scrollbar thumb at the same time. + +`StyleProps` +------------ + +A `StyleProps` is a plain table of properties for use in `ui.Style`. + +### Field formats + +`StyleProps` has a specific field formats for positions and rectangles: + +* 2D vector: A table of two numbers, indicating a position, size, or offset, + e.g. `{5, 7}`. +* Rectangle: A table of four numbers in order of left, top, right, bottom, e.g. + `{10, 2, 10, 15}`. + +### Recognized fields + +All properties are optional. Invalid properties are ignored. + +* `pos` (2D vector): The position of the box in normalized coordinates relative + to the parent box, i.e. `{0, 0}` is the top left and `{1, 1}` is the bottom + right. Default `{0, 0}`. +* `size` (2D vector): The size of the box in normalized coordinates relative to + the parent box. Default `{1, 1}`. +* `anchor` (2D vector): The point at which to position the box from in + normalized coordinates relative to the size of the box. E.g. `{1/2, 1/2}` + means to position the box from its center. Default `{0, 0}`. +* `apos` (2D vector): An absolute pixel amount to add to the position + calculated from `pos`. Default `{0, 0}`. +* `asize` (2D vector): An absolute pixel amount to add to the size calculated + from `size`. Default `{0, 0}`. +* `margin` (rectangle): Margin in pixels of blank space between the box's size + and the edges of the box, i.e. the box is drawn this many pixels inwards from + the calculated size. Margins may be negative. Default `{0, 0, 0, 0}`. +* `padding` (rectangle): Padding in pixels between the edges of the box and + content inside of it, such as children and foreground images. Padding may be + negative. Default `{0, 0, 0, 0}`. +* `bg_image` (texture): Image to draw as the background of the box. The image + is stretched to fit the box's edges. Default none. +* `bg_fill` (ColorSpec): Color to fill the box with, drawn behind the + background image. Default transparent. +* `bg_tint` (ColorSpec): Color to multiply the background image by. Default + white. +* `bg_source` (rectangle): Allows a sub-rectangle of the background image to be + drawn from instead of the whole image. Default `{0, 0, 1, 1}`. + * Uses normalized coordinates relative to the size of the texture. E.g. + `{1/2, 1/2, 1, 1}` draws the lower right quadrant of the texture. This + makes source rectangles friendly to texture packs with varying base + texture sizes. + * Coordinates may be negative, which flips the image. E.g. `{1, 0, 0, 1}` + flips the image horizontally. + * Coordinates may extend past the image boundaries, which repeats the + texture. E.g. `{-1, 0, 2, 1}` displays three copies of the texture side + by side. +* `bg_middle` (rectangle): If the texture is to be a 9-slice image (see + ), then this defines the + number of pixels on each border of the 9-slice image. Default `{0, 0, 0, 0}`. + * Uses normalized coordinates relative to the size of the texture. E.g. + `{2/16, 1/16, 2/16, 1/16}` will make the horizontal borders 2 pixels and + the vertical borders 1 pixel on a 16 by 16 image. +* `bg_frames` (integer): If the background image should be animated, this is + the number of frames in the animation. Default 1. + * If less than two, the image will be static. + * Nothing will happen if `bg_frame_time` and `bg_frame_offset` aren't set + to reasonable values. Default 1. +* `bg_frame_time` (integer): Time in milliseconds to display each frame in an + animated background image for. Default 1000. +* `bg_frame_offset` (2D vector): If the background image is animated, this is + the amount to offset the source rectangle by. Default `{0, 0}`. + * For example, if `bg_source` is `{0, 0, 1, 1/4}` and `bg_frames` is 4, + then a `frame_offset` of `{0, 1/4}` will animate the image over four + frames stacked on top of each other. +* `fg_image` (texture): Image to draw in the foreground of the box. The + foreground image maintains its aspect ratio and doesn't necessarily fit the + entire box. It can be positioned in different parts of the box. Default none. +* `fg_fill` (ColorSpec): Color the fill behind the foreground image. Default + transparent. +* `fg_tint` (ColorSpec): Color to multiply the foreground image by. Default + white. +* `fg_source` (rectangle): See `bg_source`, but for `fg_image`. +* `fg_middle` (rectangle): See `bg_middle`, but for `fg_image`. +* `fg_frames` (integer): See `bg_frames`, but for `fg_image`. +* `fg_frame_time` (integer): See `bg_frame_time`, but for `fg_image`. +* `fg_frame_offset` (2D vector): See `bg_frame_offset`, but for `fg_image`. +* `fg_scale` (number): Scales the foreground image up by a specific factor. + Default 1. + * For instance, a factor of two will make the foreground image twice as + large as its normal size. + * A scale of zero will make the image take up as much room as possible + without being larger than the box itself. +* `fg_halign`: Determines how to horizontally position the foreground image + within the box. One of `"left"`, `"center"`, `"right"`. Default `"center"`. +* `fg_valign`: Determines how to vertically position the foreground image + within the box. One of `"top"`, `"center"`, `"bottom"`. Default `"center"`. +* `visible` (boolean): Determines if the box should be drawn. Default true. +* `noclip` (boolean): Determines if the box should not be clipped to the + boundaries of its parent box. If true, the entire box will be displayed no + matter what its position and size are. Default false. + + Formspec ======== @@ -3956,6 +4588,8 @@ Helper functions * returns time with microsecond precision. May not return wall time. * `table.copy(table)`: returns a table * returns a deep copy of `table` +* `table.shallow_copy(table)`: + * returns a shallow copy of `table` * `table.indexof(list, val)`: returns the smallest numerical index containing the value `val` in the table `list`. Non-numerical indices are ignored. If `val` could not be found, `-1` is returned. `list` must not have @@ -3963,6 +4597,9 @@ Helper functions * `table.insert_all(table, other_table)`: * Appends all values in `other_table` to `table` - uses `#table + 1` to find new indices. +* `table.merge(...)`: + * Merges multiple tables together into a single new table using + `table.insert_all()`. * `table.key_value_swap(t)`: returns a table with keys and values swapped * If multiple keys in `t` map to the same value, it is unspecified which value maps to that key. @@ -5433,6 +6070,38 @@ Utilities * `minetest.urlencode(str)`: Encodes non-unreserved URI characters by a percent sign followed by two hex digits. See [RFC 3986, section 2.3](https://datatracker.ietf.org/doc/html/rfc3986#section-2.3). +* `minetest.class([super])`: Creates a new metatable-based class. + * `super` (optional): The superclass (i.e. the metatable) of the newly + created class. If nil, an empty table will be used. + * Lua metamethods may be added to the class, but they are not automatically + inherited. Note that `__index` and `__call` metafields are automatically + added to the metatable. + * When a new object is constructed, the `new()` method, if present, will be + called. + * Example: The following code, demonstrating classes, inheritance, and + overridden methods, will print "Bob is a person and is happy": + ```lua + local Person = minetest.class() + function Person:new(name) + self.name = name + end + + function Person:describe() + return self.name .. " is a person" + end + + local HappyPerson = minetest.class(Person) + function HappyPerson:describe() + return Person.describe(self) .. " and is happy" + end + + local person = HappyPerson("Bob") + if minetest.is_instance(person, Person) then + print(person:describe()) + end + ``` +* `minetest.is_instance(obj, class)`: Returns true if and only if `obj` is an + instance of `class` or any of its subclasses. Logging -------