From 33ada16ff7c76af8e9115fe23e83abd2cd612d61 Mon Sep 17 00:00:00 2001 From: v-rob Date: Sat, 20 May 2023 00:18:46 -0700 Subject: [PATCH 1/3] Add Lua binary network serialization functions --- doc/lua_api.md | 51 ++++++- games/devtest/mods/unittests/misc.lua | 199 +++++++++++++++++++++++++ src/script/lua_api/l_util.cpp | 203 ++++++++++++++++++++++++++ src/script/lua_api/l_util.h | 10 ++ src/util/serialize.cpp | 129 ++++++++++------ src/util/serialize.h | 8 +- 6 files changed, 551 insertions(+), 49 deletions(-) diff --git a/doc/lua_api.md b/doc/lua_api.md index ca9338271..99cae7343 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -5443,6 +5443,9 @@ Utilities * `minetest.colorspec_to_colorstring(colorspec)`: Converts a ColorSpec to a ColorString. If the ColorSpec is invalid, returns `nil`. * `colorspec`: The ColorSpec to convert +* `minetest.colorspec_to_colorint(colorspec)`: Converts a ColorSpec to integer + form. If the ColorSpec is invalid, returns `nil`. + * `colorspec`: The ColorSpec to convert * `minetest.colorspec_to_bytes(colorspec)`: Converts a ColorSpec to a raw string of four bytes in an RGBA layout, returned as a string. * `colorspec`: The ColorSpec to convert @@ -5451,8 +5454,8 @@ Utilities * `width`: Width of the image * `height`: Height of the image * `data`: Image data, one of: - * array table of ColorSpec, length must be width*height - * string with raw RGBA pixels, length must be width*height*4 + * array table of ColorSpec, length must be `width * height` + * string with raw RGBA pixels, length must be `width * height * 4` * `compression`: Optional zlib compression level, number in range 0 to 9. The data is one-dimensional, starting in the upper left corner of the image and laid out in scanlines going from left to right, then top to bottom. @@ -6975,6 +6978,50 @@ Misc. * Example: `deserialize('print("foo")')`, returns `nil` (function call fails), returns `error:[string "print("foo")"]:1: attempt to call global 'print' (a nil value)` +* `minetest.encode_network(format, ...)`: Encodes numbers and strings in binary + format suitable for network transfer according to a format string. + * Each character in the format string corresponds to an argument to the + function. Possible format characters: + * `b`: Signed 8-bit integer + * `h`: Signed 16-bit integer + * `i`: Signed 32-bit integer + * `l`: Signed 64-bit integer + * `B`: Unsigned 8-bit integer + * `H`: Unsigned 16-bit integer + * `I`: Unsigned 32-bit integer + * `L`: Unsigned 64-bit integer + * `f`: Single-precision floating point number + * `s`: 16-bit size-prefixed string. Max 64 KB in size + * `S`: 32-bit size-prefixed string. Max 64 MB in size + * `z`: Null-terminated string. Cannot have embedded null characters + * `Z`: Verbatim string with no size or terminator + * ` `: Spaces are ignored + * Integers are encoded in big-endian format, and floating point numbers are + encoded in IEEE-754 format. Note that the full range of 64-bit integers + cannot be represented in Lua's doubles. + * If integers outside of the range of the corresponding type are encoded, + integer wraparound will occur. + * If a string that is too long for a size-prefixed string is encoded, it + will be truncated. + * If a string with an embedded null character is encoded as a null + terminated string, it is truncated to the first null character. + * Verbatim strings are added directly to the output as-is and can therefore + have any size or contents, but the code on the decoding end cannot + automatically detect its length. +* `minetest.decode_network(format, data, ...)`: Decodes numbers and strings + from a binary format created by `minetest.encode_network()` according to a + format string. + * The format string follows the same rules as `minetest.encode_network()`. + The decoded values are returned as individual values from the function. + * `Z` has special behavior; an extra argument has to be passed to the + function for every `Z` specifier denoting how many characters to read. + To read all remaining characters, use a size of `-1`. + * If the end of the data is encountered while still reading values from the + string, values of the correct type will still be returned, but strings of + variable length will be truncated, and numbers and verbatim strings will + use zeros for the missing bytes. + * If a size-prefixed string has a size that is greater than the maximum, it + will be truncated and the rest of the characters skipped. * `minetest.compress(data, method, ...)`: returns `compressed_data` * Compress a string of data. * `method` is a string identifying the compression method to be used. diff --git a/games/devtest/mods/unittests/misc.lua b/games/devtest/mods/unittests/misc.lua index d7620652b..987f96509 100644 --- a/games/devtest/mods/unittests/misc.lua +++ b/games/devtest/mods/unittests/misc.lua @@ -240,3 +240,202 @@ local function test_gennotify_api() assert(#custom == 0, "custom ids not empty") end unittests.register("test_gennotify_api", test_gennotify_api) + +unittests.register("test_encode_network", function() + -- 8-bit integers + assert(minetest.encode_network("bbbbbbb", 0, 1, -1, -128, 127, 255, 256) == + "\x00\x01\xFF\x80\x7F\xFF\x00") + assert(minetest.encode_network("BBBBBBB", 0, 1, -1, -128, 127, 255, 256) == + "\x00\x01\xFF\x80\x7F\xFF\x00") + + -- 16-bit integers + assert(minetest.encode_network("hhhhhhhh", + 0, 1, 257, -1, + -32768, 32767, 65535, 65536) == + "\x00\x00".."\x00\x01".."\x01\x01".."\xFF\xFF".. + "\x80\x00".."\x7F\xFF".."\xFF\xFF".."\x00\x00") + assert(minetest.encode_network("HHHHHHHH", + 0, 1, 257, -1, + -32768, 32767, 65535, 65536) == + "\x00\x00".."\x00\x01".."\x01\x01".."\xFF\xFF".. + "\x80\x00".."\x7F\xFF".."\xFF\xFF".."\x00\x00") + + -- 32-bit integers + assert(minetest.encode_network("iiiiiiii", + 0, 257, 2^24-1, -1, + -2^31, 2^31-1, 2^32-1, 2^32) == + "\x00\x00\x00\x00".."\x00\x00\x01\x01".."\x00\xFF\xFF\xFF".."\xFF\xFF\xFF\xFF".. + "\x80\x00\x00\x00".."\x7F\xFF\xFF\xFF".."\xFF\xFF\xFF\xFF".."\x00\x00\x00\x00") + assert(minetest.encode_network("IIIIIIII", + 0, 257, 2^24-1, -1, + -2^31, 2^31-1, 2^32-1, 2^32) == + "\x00\x00\x00\x00".."\x00\x00\x01\x01".."\x00\xFF\xFF\xFF".."\xFF\xFF\xFF\xFF".. + "\x80\x00\x00\x00".."\x7F\xFF\xFF\xFF".."\xFF\xFF\xFF\xFF".."\x00\x00\x00\x00") + + -- 64-bit integers + assert(minetest.encode_network("llllll", + 0, 1, + 511, -1, + 2^53-1, -2^53) == + "\x00\x00\x00\x00\x00\x00\x00\x00".."\x00\x00\x00\x00\x00\x00\x00\x01".. + "\x00\x00\x00\x00\x00\x00\x01\xFF".."\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF".. + "\x00\x1F\xFF\xFF\xFF\xFF\xFF\xFF".."\xFF\xE0\x00\x00\x00\x00\x00\x00") + assert(minetest.encode_network("LLLLLL", + 0, 1, + 511, -1, + 2^53-1, -2^53) == + "\x00\x00\x00\x00\x00\x00\x00\x00".."\x00\x00\x00\x00\x00\x00\x00\x01".. + "\x00\x00\x00\x00\x00\x00\x01\xFF".."\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF".. + "\x00\x1F\xFF\xFF\xFF\xFF\xFF\xFF".."\xFF\xE0\x00\x00\x00\x00\x00\x00") + + -- Strings + local max_16 = string.rep("*", 2^16 - 1) + local max_32 = string.rep("*", 2^26) + + assert(minetest.encode_network("ssss", + "", "hello", + max_16, max_16.."too long") == + "\x00\x00".. "\x00\x05hello".. + "\xFF\xFF"..max_16.."\xFF\xFF"..max_16) + assert(minetest.encode_network("SSSS", + "", "hello", + max_32, max_32.."too long") == + "\x00\x00\x00\x00".. "\x00\x00\x00\x05hello".. + "\x04\x00\x00\x00"..max_32.."\x04\x00\x00\x00"..max_32) + assert(minetest.encode_network("zzzz", + "", "hello", "hello\0embedded", max_16.."longer") == + "\0".."hello\0".."hello\0".. max_16.."longer\0") + assert(minetest.encode_network("ZZZZ", + "", "hello", "hello\0embedded", max_16.."longer") == + "".."hello".."hello\0embedded"..max_16.."longer") + + -- Spaces + assert(minetest.encode_network("B I", 255, 2^31) == "\xFF\x80\x00\x00\x00") + assert(minetest.encode_network(" B Zz ", 15, "abc", "xyz") == "\x0Fabcxyz\0") + + -- Empty format strings + assert(minetest.encode_network("") == "") + assert(minetest.encode_network(" ", 5, "extra args") == "") +end) + +unittests.register("test_decode_network", function() + local d + + -- 8-bit integers + d = {minetest.decode_network("bbbbb", "\x00\x01\x7F\x80\xFF")} + assert(#d == 5) + assert(d[1] == 0 and d[2] == 1 and d[3] == 127 and d[4] == -128 and d[5] == -1) + + d = {minetest.decode_network("BBBBB", "\x00\x01\x7F\x80\xFF")} + assert(#d == 5) + assert(d[1] == 0 and d[2] == 1 and d[3] == 127 and d[4] == 128 and d[5] == 255) + + -- 16-bit integers + d = {minetest.decode_network("hhhhhh", + "\x00\x00".."\x00\x01".."\x01\x01".. + "\x7F\xFF".."\x80\x00".."\xFF\xFF")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 257 and + d[4] == 32767 and d[5] == -32768 and d[6] == -1) + + d = {minetest.decode_network("HHHHHH", + "\x00\x00".."\x00\x01".."\x01\x01".. + "\x7F\xFF".."\x80\x00".."\xFF\xFF")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 257 and + d[4] == 32767 and d[5] == 32768 and d[6] == 65535) + + -- 32-bit integers + d = {minetest.decode_network("iiiiii", + "\x00\x00\x00\x00".."\x00\x00\x00\x01".."\x00\xFF\xFF\xFF".. + "\x7F\xFF\xFF\xFF".."\x80\x00\x00\x00".."\xFF\xFF\xFF\xFF")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 2^24-1 and + d[4] == 2^31-1 and d[5] == -2^31 and d[6] == -1) + + d = {minetest.decode_network("IIIIII", + "\x00\x00\x00\x00".."\x00\x00\x00\x01".."\x00\xFF\xFF\xFF".. + "\x7F\xFF\xFF\xFF".."\x80\x00\x00\x00".."\xFF\xFF\xFF\xFF")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 2^24-1 and + d[4] == 2^31-1 and d[5] == 2^31 and d[6] == 2^32-1) + + -- 64-bit integers + d = {minetest.decode_network("llllll", + "\x00\x00\x00\x00\x00\x00\x00\x00".."\x00\x00\x00\x00\x00\x00\x00\x01".. + "\x00\x00\x00\x00\x00\x00\x01\xFF".."\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF".. + "\x00\x1F\xFF\xFF\xFF\xFF\xFF\xFF".."\xFF\xE0\x00\x00\x00\x00\x00\x00")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 511 and + d[4] == -1 and d[5] == 2^53-1 and d[6] == -2^53) + + d = {minetest.decode_network("LLLLLL", + "\x00\x00\x00\x00\x00\x00\x00\x00".."\x00\x00\x00\x00\x00\x00\x00\x01".. + "\x00\x00\x00\x00\x00\x00\x01\xFF".."\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF".. + "\x00\x1F\xFF\xFF\xFF\xFF\xFF\xFF".."\xFF\xE0\x00\x00\x00\x00\x00\x00")} + assert(#d == 6) + assert(d[1] == 0 and d[2] == 1 and d[3] == 511 and + d[4] == 2^64-1 and d[5] == 2^53-1 and d[6] == 2^64 - 2^53) + + -- Floating point numbers + local enc = minetest.encode_network("fff", + 0.0, 123.456, -987.654) + assert(#enc == 3 * 4) + + d = {minetest.decode_network("fff", enc)} + assert(#d == 3) + assert(d[1] == 0.0 and d[2] > 123.45 and d[2] < 123.46 and + d[3] > -987.66 and d[3] < -987.65) + + -- Strings + local max_16 = string.rep("*", 2^16 - 1) + local max_32 = string.rep("*", 2^26) + + d = {minetest.decode_network("ssss", + "\x00\x00".."\x00\x05hello".."\xFF\xFF"..max_16.."\x00\xFFtoo short")} + assert(#d == 4) + assert(d[1] == "" and d[2] == "hello" and d[3] == max_16 and d[4] == "too short") + + d = {minetest.decode_network("SSSSS", + "\x00\x00\x00\x00".."\x00\x00\x00\x05hello".. + "\x04\x00\x00\x00"..max_32.."\x04\x00\x00\x08"..max_32.."too long".. + "\x00\x00\x00\xFFtoo short")} + assert(#d == 5) + assert(d[1] == "" and d[2] == "hello" and + d[3] == max_32 and d[4] == max_32 and d[5] == "too short") + + d = {minetest.decode_network("zzzz", "\0".."hello\0".."missing end")} + assert(#d == 4) + assert(d[1] == "" and d[2] == "hello" and d[3] == "missing end" and d[4] == "") + + -- Verbatim strings + d = {minetest.decode_network("ZZZZ", "xxxyyyyyzzz", 3, 0, 5, -1)} + assert(#d == 4) + assert(d[1] == "xxx" and d[2] == "" and d[3] == "yyyyy" and d[4] == "zzz") + + -- Read past end + d = {minetest.decode_network("bhilBHILf", "")} + assert(#d == 9) + assert(d[1] == 0 and d[2] == 0 and d[3] == 0 and d[4] == 0 and + d[5] == 0 and d[6] == 0 and d[7] == 0 and d[8] == 0 and d[9] == 0.0) + + d = {minetest.decode_network("ZsSzZ", "xx", 4, 4)} + assert(#d == 5) + assert(d[1] == "xx\0\0" and d[2] == "" and d[3] == "" and + d[4] == "" and d[5] == "\0\0\0\0") + + -- Spaces + d = {minetest.decode_network("B I", "\xFF\x80\x00\x00\x00")} + assert(#d == 2) + assert(d[1] == 255 and d[2] == 2^31) + + d = {minetest.decode_network(" B Zz ", "\x0Fabcxyz\0", 3)} + assert(#d == 3) + assert(d[1] == 15 and d[2] == "abc" and d[3] == "xyz") + + -- Empty format strings + d = {minetest.decode_network("", "some random data")} + assert(#d == 0) + d = {minetest.decode_network(" ", "some random data", 3, 5)} + assert(#d == 0) +end) diff --git a/src/script/lua_api/l_util.cpp b/src/script/lua_api/l_util.cpp index 6a1cdc9df..899c1b5eb 100644 --- a/src/script/lua_api/l_util.cpp +++ b/src/script/lua_api/l_util.cpp @@ -583,6 +583,20 @@ int ModApiUtil::l_colorspec_to_colorstring(lua_State *L) return 0; } +// colorspec_to_colorint(colorspec) +int ModApiUtil::l_colorspec_to_colorint(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + + video::SColor color(0); + if (read_color(L, 1, &color)) { + lua_pushnumber(L, color.color); + return 1; + } + + return 0; +} + // colorspec_to_bytes(colorspec) int ModApiUtil::l_colorspec_to_bytes(lua_State *L) { @@ -603,6 +617,183 @@ int ModApiUtil::l_colorspec_to_bytes(lua_State *L) return 0; } +// encode_network(format, ...) +int ModApiUtil::l_encode_network(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + + std::string format = readParam(L, 1); + std::ostringstream os(std::ios_base::binary); + + int arg = 2; + for (size_t i = 0; i < format.size(); i++) { + switch (format[i]) { + case 'b': + // Casting the double to a signed integer larger than the target + // integer results in proper integer wraparound behavior. + writeS8(os, (s64)luaL_checknumber(L, arg)); + break; + case 'h': + writeS16(os, (s64)luaL_checknumber(L, arg)); + break; + case 'i': + writeS32(os, (s64)luaL_checknumber(L, arg)); + break; + case 'l': + writeS64(os, (s64)luaL_checknumber(L, arg)); + break; + case 'B': + // Casting to an unsigned integer doesn't result in the proper + // integer conversions being applied, so we still use signed. + writeU8(os, (s64)luaL_checknumber(L, arg)); + break; + case 'H': + writeU16(os, (s64)luaL_checknumber(L, arg)); + break; + case 'I': + writeU32(os, (s64)luaL_checknumber(L, arg)); + break; + case 'L': + // For the 64-bit integers, we can never experience integer + // overflow due to the limited range of Lua's doubles, but we can + // have underflow, hence why we cast to s64 first. + writeU64(os, (s64)luaL_checknumber(L, arg)); + break; + case 'f': + writeF32(os, luaL_checknumber(L, arg)); + break; + case 's': { + std::string str = readParam(L, arg); + os << serializeString16(str, true); + break; + } + case 'S': { + std::string str = readParam(L, arg); + os << serializeString32(str, true); + break; + } + case 'z': { + std::string str = readParam(L, arg); + os << str.substr(0, strlen(str.c_str())) << '\0'; + break; + } + case 'Z': + os << readParam(L, arg); + break; + case ' ': + // Continue because we don't want to increment arg. + continue; + default: + throw LuaError("Invalid format string"); + } + + arg++; + } + + std::string data = os.str(); + lua_pushlstring(L, data.c_str(), data.size()); + return 1; +} + +// decode_network(format, data) +int ModApiUtil::l_decode_network(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + + std::string format = readParam(L, 1); + std::string data = readParam(L, 2); + std::istringstream is(data, std::ios_base::binary); + + // Make sure we have space for all our returned arguments. + lua_checkstack(L, format.size()); + + // Set up tracking for verbatim strings and the number of return values. + int num_args = lua_gettop(L); + int arg = 3; + int ret = 0; + + for (size_t i = 0; i < format.size(); i++) { + switch (format[i]) { + case 'b': + lua_pushnumber(L, readS8(is)); + break; + case 'h': + lua_pushnumber(L, readS16(is)); + break; + case 'i': + lua_pushnumber(L, readS32(is)); + break; + case 'l': + lua_pushnumber(L, readS64(is)); + break; + case 'B': + lua_pushnumber(L, readU8(is)); + break; + case 'H': + lua_pushnumber(L, readU16(is)); + break; + case 'I': + lua_pushnumber(L, readU32(is)); + break; + case 'L': + lua_pushnumber(L, readU64(is)); + break; + case 'f': + lua_pushnumber(L, readF32(is)); + break; + case 's': { + std::string str = deSerializeString16(is, true); + lua_pushlstring(L, str.c_str(), str.size()); + break; + } + case 'S': { + std::string str = deSerializeString32(is, true); + lua_pushlstring(L, str.c_str(), str.size()); + break; + } + case 'z': { + std::string str; + std::getline(is, str, '\0'); + + lua_pushlstring(L, str.c_str(), str.size()); + break; + } + case 'Z': { + if (arg > num_args) { + throw LuaError("Missing verbatim string size"); + } + + double size = luaL_checknumber(L, arg); + std::string str; + + if (size < 0) { + // Read the entire rest of the input stream. + std::ostringstream os(std::ios_base::binary); + os << is.rdbuf(); + str = os.str(); + } else if (size != 0) { + // Read the specified number of characters. + str.resize(size); + is.read(&str[0], size); + } + + lua_pushlstring(L, str.c_str(), str.size()); + arg++; + break; + } + case ' ': + // Continue because we don't want to increment ret. + continue; + default: + throw LuaError("Invalid format string"); + } + + ret++; + } + + return ret; +} + // encode_png(w, h, data, level) int ModApiUtil::l_encode_png(lua_State *L) { @@ -691,8 +882,12 @@ void ModApiUtil::Initialize(lua_State *L, int top) API_FCT(get_version); API_FCT(sha1); API_FCT(colorspec_to_colorstring); + API_FCT(colorspec_to_colorint); API_FCT(colorspec_to_bytes); + API_FCT(encode_network); + API_FCT(decode_network); + API_FCT(encode_png); API_FCT(get_last_run_mod); @@ -724,11 +919,15 @@ void ModApiUtil::InitializeClient(lua_State *L, int top) API_FCT(get_version); API_FCT(sha1); API_FCT(colorspec_to_colorstring); + API_FCT(colorspec_to_colorint); API_FCT(colorspec_to_bytes); API_FCT(get_last_run_mod); API_FCT(set_last_run_mod); + API_FCT(encode_network); + API_FCT(decode_network); + API_FCT(urlencode); LuaSettings::create(L, g_settings, g_settings_path); @@ -767,8 +966,12 @@ void ModApiUtil::InitializeAsync(lua_State *L, int top) API_FCT(get_version); API_FCT(sha1); API_FCT(colorspec_to_colorstring); + API_FCT(colorspec_to_colorint); API_FCT(colorspec_to_bytes); + API_FCT(encode_network); + API_FCT(decode_network); + API_FCT(encode_png); API_FCT(get_last_run_mod); diff --git a/src/script/lua_api/l_util.h b/src/script/lua_api/l_util.h index 07c4b86eb..5d848176e 100644 --- a/src/script/lua_api/l_util.h +++ b/src/script/lua_api/l_util.h @@ -20,6 +20,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #pragma once #include "lua_api/l_base.h" +#include "util/serialize.h" class AsyncEngine; @@ -116,9 +117,18 @@ private: // colorspec_to_colorstring(colorspec) static int l_colorspec_to_colorstring(lua_State *L); + // colorspec_to_colorint(colorspec) + static int l_colorspec_to_colorint(lua_State *L); + // colorspec_to_bytes(colorspec) static int l_colorspec_to_bytes(lua_State *L); + // encode_network(format, ...) + static int l_encode_network(lua_State *L); + + // decode_network(format, data) + static int l_decode_network(lua_State *L); + // encode_png(w, h, data, level) static int l_encode_png(lua_State *L); diff --git a/src/util/serialize.cpp b/src/util/serialize.cpp index e1552f602..83d6280cb 100644 --- a/src/util/serialize.cpp +++ b/src/util/serialize.cpp @@ -34,39 +34,54 @@ FloatType g_serialize_f32_type = FLOATTYPE_UNKNOWN; //// String //// -std::string serializeString16(std::string_view plain) +std::string serializeString16(std::string_view plain, bool truncate) { std::string s; - char buf[2]; + size_t size = plain.size(); - if (plain.size() > STRING_MAX_LEN) - throw SerializationError("String too long for serializeString16"); - s.reserve(2 + plain.size()); + if (size > STRING_MAX_LEN) { + if (truncate) { + size = STRING_MAX_LEN; + } else { + throw SerializationError("String too long for serializeString16"); + } + } - writeU16((u8 *)&buf[0], plain.size()); - s.append(buf, 2); + char size_buf[2]; + writeU16((u8 *)size_buf, size); + + s.reserve(2 + size); + s.append(size_buf, 2); + s.append(plain.substr(0, size)); - s.append(plain); return s; } -std::string deSerializeString16(std::istream &is) +std::string deSerializeString16(std::istream &is, bool truncate) { std::string s; - char buf[2]; + char size_buf[2]; - is.read(buf, 2); - if (is.gcount() != 2) + is.read(size_buf, 2); + if (is.gcount() != 2) { + if (truncate) { + return s; + } throw SerializationError("deSerializeString16: size not read"); + } - u16 s_size = readU16((u8 *)buf); - if (s_size == 0) + u16 size = readU16((u8 *)size_buf); + if (size == 0) { return s; + } - s.resize(s_size); - is.read(&s[0], s_size); - if (is.gcount() != s_size) + s.resize(size); + is.read(&s[0], size); + if (truncate) { + s.resize(is.gcount()); + } else if (is.gcount() != size) { throw SerializationError("deSerializeString16: couldn't read all chars"); + } return s; } @@ -76,44 +91,72 @@ std::string deSerializeString16(std::istream &is) //// Long String //// -std::string serializeString32(std::string_view plain) +std::string serializeString32(std::string_view plain, bool truncate) { std::string s; - char buf[4]; + size_t size = plain.size(); - if (plain.size() > LONG_STRING_MAX_LEN) - throw SerializationError("String too long for serializeLongString"); - s.reserve(4 + plain.size()); + if (size > LONG_STRING_MAX_LEN) { + if (truncate) { + size = LONG_STRING_MAX_LEN; + } else { + throw SerializationError("String too long for serializeString32"); + } + } + + char size_buf[4]; + writeU32((u8 *)size_buf, size); + + s.reserve(4 + size); + s.append(size_buf, 4); + s.append(plain.substr(0, size)); - writeU32((u8*)&buf[0], plain.size()); - s.append(buf, 4); - s.append(plain); return s; } -std::string deSerializeString32(std::istream &is) +std::string deSerializeString32(std::istream &is, bool truncate) { std::string s; - char buf[4]; + char size_buf[4]; - is.read(buf, 4); - if (is.gcount() != 4) - throw SerializationError("deSerializeLongString: size not read"); - - u32 s_size = readU32((u8 *)buf); - if (s_size == 0) - return s; - - // We don't really want a remote attacker to force us to allocate 4GB... - if (s_size > LONG_STRING_MAX_LEN) { - throw SerializationError("deSerializeLongString: " - "string too long: " + itos(s_size) + " bytes"); + is.read(size_buf, 4); + if (is.gcount() != 4) { + if (truncate) { + return s; + } + throw SerializationError("deSerializeString32: size not read"); } - s.resize(s_size); - is.read(&s[0], s_size); - if ((u32)is.gcount() != s_size) - throw SerializationError("deSerializeLongString: couldn't read all chars"); + u32 size = readU32((u8 *)size_buf); + u32 ignore = 0; + if (size == 0) { + return s; + } + + if (size > LONG_STRING_MAX_LEN) { + if (truncate) { + ignore = size - LONG_STRING_MAX_LEN; + size = LONG_STRING_MAX_LEN; + } else { + // We don't really want a remote attacker to force us to allocate 4GB... + throw SerializationError("deSerializeString32: " + "string too long: " + itos(size) + " bytes"); + } + } + + s.resize(size); + is.read(&s[0], size); + if (truncate) { + s.resize(is.gcount()); + } else if (is.gcount() != size) { + throw SerializationError("deSerializeString32: couldn't read all chars"); + } + + // If the string was truncated due to exceeding the string max length, we + // need to ignore the rest of the characters. + if (truncate) { + is.seekg(ignore, std::ios_base::cur); + } return s; } diff --git a/src/util/serialize.h b/src/util/serialize.h index 00250ece5..d2956dce5 100644 --- a/src/util/serialize.h +++ b/src/util/serialize.h @@ -450,16 +450,16 @@ inline v3f clampToF1000(v3f v) } // Creates a string with the length as the first two bytes -std::string serializeString16(std::string_view plain); +std::string serializeString16(std::string_view plain, bool truncate = false); // Reads a string with the length as the first two bytes -std::string deSerializeString16(std::istream &is); +std::string deSerializeString16(std::istream &is, bool truncate = false); // Creates a string with the length as the first four bytes -std::string serializeString32(std::string_view plain); +std::string serializeString32(std::string_view plain, bool truncate = false); // Reads a string with the length as the first four bytes -std::string deSerializeString32(std::istream &is); +std::string deSerializeString32(std::istream &is, bool truncate = false); // Creates a string encoded in JSON format (almost equivalent to a C string literal) std::string serializeJsonString(std::string_view plain); From 3afcbf6480489d033cf7da15533b0e592b873441 Mon Sep 17 00:00:00 2001 From: v-rob Date: Wed, 24 Jan 2024 20:57:46 -0800 Subject: [PATCH 2/3] Create C++ backend GUI code --- src/client/client.h | 1 + src/client/clientevent.h | 5 + src/client/game.cpp | 21 +- src/client/render/core.cpp | 3 +- src/client/render/core.h | 2 +- src/client/render/pipeline.h | 1 + src/client/render/plain.cpp | 16 + src/client/renderingengine.cpp | 4 +- src/client/renderingengine.h | 2 +- src/gui/CMakeLists.txt | 6 + src/gui/box.cpp | 452 ++++++++++++++++++++++++++++ src/gui/box.h | 163 ++++++++++ src/gui/elem.cpp | 147 +++++++++ src/gui/elem.h | 98 ++++++ src/gui/generic_elems.cpp | 58 ++++ src/gui/generic_elems.h | 52 ++++ src/gui/manager.cpp | 192 ++++++++++++ src/gui/manager.h | 120 ++++++++ src/gui/texture.cpp | 255 ++++++++++++++++ src/gui/texture.h | 185 ++++++++++++ src/gui/window.cpp | 284 +++++++++++++++++ src/gui/window.h | 102 +++++++ src/network/clientopcodes.cpp | 2 +- src/network/clientpackethandler.cpp | 11 + src/network/networkprotocol.h | 7 + src/network/serveropcodes.cpp | 2 +- src/script/lua_api/l_server.cpp | 14 + src/script/lua_api/l_server.h | 3 + src/server.cpp | 13 + src/server.h | 2 + 30 files changed, 2215 insertions(+), 8 deletions(-) create mode 100644 src/gui/box.cpp create mode 100644 src/gui/box.h create mode 100644 src/gui/elem.cpp create mode 100644 src/gui/elem.h create mode 100644 src/gui/generic_elems.cpp create mode 100644 src/gui/generic_elems.h create mode 100644 src/gui/manager.cpp create mode 100644 src/gui/manager.h create mode 100644 src/gui/texture.cpp create mode 100644 src/gui/texture.h create mode 100644 src/gui/window.cpp create mode 100644 src/gui/window.h diff --git a/src/client/client.h b/src/client/client.h index 9f898e78a..9f91163e8 100644 --- a/src/client/client.h +++ b/src/client/client.h @@ -204,6 +204,7 @@ public: void handleCommand_InventoryFormSpec(NetworkPacket* pkt); void handleCommand_DetachedInventory(NetworkPacket* pkt); void handleCommand_ShowFormSpec(NetworkPacket* pkt); + void handleCommand_UiMessage(NetworkPacket* pkt); void handleCommand_SpawnParticle(NetworkPacket* pkt); void handleCommand_AddParticleSpawner(NetworkPacket* pkt); void handleCommand_DeleteParticleSpawner(NetworkPacket* pkt); diff --git a/src/client/clientevent.h b/src/client/clientevent.h index 243a94596..1ca82669b 100644 --- a/src/client/clientevent.h +++ b/src/client/clientevent.h @@ -37,6 +37,7 @@ enum ClientEventType : u8 CE_DEATHSCREEN, CE_SHOW_FORMSPEC, CE_SHOW_LOCAL_FORMSPEC, + CE_UI_MESSAGE, CE_SPAWN_PARTICLE, CE_ADD_PARTICLESPAWNER, CE_DELETE_PARTICLESPAWNER, @@ -106,6 +107,10 @@ struct ClientEvent std::string *formspec; std::string *formname; } show_formspec; + struct + { + std::string *data; + } ui_message; // struct{ //} textures_updated; ParticleParameters *spawn_particle; diff --git a/src/client/game.cpp b/src/client/game.cpp index 761484c5c..1cd74559f 100644 --- a/src/client/game.cpp +++ b/src/client/game.cpp @@ -51,6 +51,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "gui/guiPasswordChange.h" #include "gui/guiVolumeChange.h" #include "gui/mainmenumanager.h" +#include "gui/manager.h" #include "gui/profilergraph.h" #include "mapblock.h" #include "minimap.h" @@ -902,6 +903,7 @@ private: void handleClientEvent_Deathscreen(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_ShowFormSpec(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_ShowLocalFormSpec(ClientEvent *event, CameraOrientation *cam); + void handleClientEvent_UiMessage(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_HandleParticleEvent(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_HudAdd(ClientEvent *event, CameraOrientation *cam); @@ -950,6 +952,7 @@ private: std::unique_ptr m_game_ui; GUIChatConsole *gui_chat_console = nullptr; // Free using ->Drop() + ui::GUIManagerElem *gui_manager_elem = nullptr; // Free using ->Drop() MapDrawControl *draw_control = nullptr; Camera *camera = nullptr; Clouds *clouds = nullptr; // Free using ->Drop() @@ -1290,6 +1293,8 @@ void Game::shutdown() if (formspec) formspec->quitMenu(); + ui::g_manager.reset(); + // Clear text when exiting. m_game_ui->clearText(); @@ -1303,6 +1308,8 @@ void Game::shutdown() if (gui_chat_console) gui_chat_console->drop(); + if (gui_manager_elem) + gui_manager_elem->drop(); if (sky) sky->drop(); @@ -1560,6 +1567,8 @@ bool Game::createClient(const GameStartData &start_data) if (mapper && client->modsLoaded()) client->getScript()->on_minimap_ready(mapper); + ui::g_manager.setClient(client); + return true; } @@ -1577,6 +1586,9 @@ bool Game::initGui() gui_chat_console = new GUIChatConsole(guienv, guienv->getRootGUIElement(), -1, chat_backend, client, &g_menumgr); + // Thingy to draw UI manager after chat but before formspecs. + gui_manager_elem = new ui::GUIManagerElem(guienv, guiroot, -1); + if (g_touchscreengui) g_touchscreengui->init(texture_src); @@ -2799,6 +2811,7 @@ const ClientEventHandler Game::clientEventHandler[CLIENTEVENT_MAX] = { {&Game::handleClientEvent_Deathscreen}, {&Game::handleClientEvent_ShowFormSpec}, {&Game::handleClientEvent_ShowLocalFormSpec}, + {&Game::handleClientEvent_UiMessage}, {&Game::handleClientEvent_HandleParticleEvent}, {&Game::handleClientEvent_HandleParticleEvent}, {&Game::handleClientEvent_HandleParticleEvent}, @@ -2904,6 +2917,12 @@ void Game::handleClientEvent_ShowLocalFormSpec(ClientEvent *event, CameraOrienta delete event->show_formspec.formname; } +void Game::handleClientEvent_UiMessage(ClientEvent *event, CameraOrientation *cam) +{ + ui::g_manager.receiveMessage(*event->ui_message.data); + delete event->ui_message.data; +} + void Game::handleClientEvent_HandleParticleEvent(ClientEvent *event, CameraOrientation *cam) { @@ -4312,7 +4331,7 @@ void Game::drawScene(ProfilerGraph *graph, RunStats *stats) draw_crosshair = false; this->m_rendering_engine->draw_scene(sky_color, this->m_game_ui->m_flags.show_hud, - draw_wield_tool, draw_crosshair); + this->m_game_ui->m_flags.show_chat, draw_wield_tool, draw_crosshair); /* Profiler graph diff --git a/src/client/render/core.cpp b/src/client/render/core.cpp index dfd9dc02e..b13a21ae3 100644 --- a/src/client/render/core.cpp +++ b/src/client/render/core.cpp @@ -36,7 +36,7 @@ RenderingCore::~RenderingCore() delete shadow_renderer; } -void RenderingCore::draw(video::SColor _skycolor, bool _show_hud, +void RenderingCore::draw(video::SColor _skycolor, bool _show_hud, bool _show_chat, bool _draw_wield_tool, bool _draw_crosshair) { v2u32 screensize = device->getVideoDriver()->getScreenSize(); @@ -46,6 +46,7 @@ void RenderingCore::draw(video::SColor _skycolor, bool _show_hud, context.draw_crosshair = _draw_crosshair; context.draw_wield_tool = _draw_wield_tool; context.show_hud = _show_hud; + context.show_chat = _show_chat; pipeline->reset(context); pipeline->run(context); diff --git a/src/client/render/core.h b/src/client/render/core.h index c5617bcb2..7858c3f02 100644 --- a/src/client/render/core.h +++ b/src/client/render/core.h @@ -53,7 +53,7 @@ public: RenderingCore &operator=(const RenderingCore &) = delete; RenderingCore &operator=(RenderingCore &&) = delete; - void draw(video::SColor _skycolor, bool _show_hud, + void draw(video::SColor _skycolor, bool _show_hud, bool _show_chat, bool _draw_wield_tool, bool _draw_crosshair); v2u32 getVirtualSize() const; diff --git a/src/client/render/pipeline.h b/src/client/render/pipeline.h index abb108652..664eb5d95 100644 --- a/src/client/render/pipeline.h +++ b/src/client/render/pipeline.h @@ -46,6 +46,7 @@ struct PipelineContext v2u32 target_size; bool show_hud {true}; + bool show_chat {true}; bool draw_wield_tool {true}; bool draw_crosshair {true}; }; diff --git a/src/client/render/plain.cpp b/src/client/render/plain.cpp index 60a732415..6343b8592 100644 --- a/src/client/render/plain.cpp +++ b/src/client/render/plain.cpp @@ -26,6 +26,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "client/hud.h" #include "client/minimap.h" #include "client/shadows/dynamicshadowsrender.h" +#include "gui/manager.h" /// Draw3D pipeline step void Draw3D::run(PipelineContext &context) @@ -43,6 +44,9 @@ void Draw3D::run(PipelineContext &context) void DrawWield::run(PipelineContext &context) { + ui::g_manager.preDraw(); + ui::g_manager.drawType(ui::WindowType::BG); + if (m_target) m_target->activate(context); @@ -60,12 +64,24 @@ void DrawHUD::run(PipelineContext &context) if (context.draw_crosshair) context.hud->drawCrosshair(); + } + ui::g_manager.drawType(ui::WindowType::MASK); + + if (context.show_hud) { context.hud->drawHotbar(context.client->getEnv().getLocalPlayer()->getWieldIndex()); + context.hud->drawLuaElements(context.client->getCamera()->getOffset()); + ui::g_manager.drawType(ui::WindowType::HUD); + context.client->getCamera()->drawNametags(); } + + if (context.show_chat) + ui::g_manager.drawType(ui::WindowType::MESSAGE); + context.device->getGUIEnvironment()->drawAll(); + ui::g_manager.drawType(ui::WindowType::FG); } diff --git a/src/client/renderingengine.cpp b/src/client/renderingengine.cpp index c1a51d289..34e21cd20 100644 --- a/src/client/renderingengine.cpp +++ b/src/client/renderingengine.cpp @@ -321,10 +321,10 @@ void RenderingEngine::finalize() core.reset(); } -void RenderingEngine::draw_scene(video::SColor skycolor, bool show_hud, +void RenderingEngine::draw_scene(video::SColor skycolor, bool show_hud, bool show_chat, bool draw_wield_tool, bool draw_crosshair) { - core->draw(skycolor, show_hud, draw_wield_tool, draw_crosshair); + core->draw(skycolor, show_hud, show_chat, draw_wield_tool, draw_crosshair); } const VideoDriverInfo &RenderingEngine::getVideoDriverInfo(irr::video::E_DRIVER_TYPE type) diff --git a/src/client/renderingengine.h b/src/client/renderingengine.h index b8293f49a..e0abd3eb4 100644 --- a/src/client/renderingengine.h +++ b/src/client/renderingengine.h @@ -117,7 +117,7 @@ public: gui::IGUIEnvironment *guienv, ITextureSource *tsrc, float dtime = 0, int percent = 0, bool sky = true); - void draw_scene(video::SColor skycolor, bool show_hud, + void draw_scene(video::SColor skycolor, bool show_hud, bool show_chat, bool draw_wield_tool, bool draw_crosshair); void initialize(Client *client, Hud *hud); diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 87575f320..e9c74f192 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -1,4 +1,7 @@ set(gui_SRCS + ${CMAKE_CURRENT_SOURCE_DIR}/box.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/elem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/generic_elems.cpp ${CMAKE_CURRENT_SOURCE_DIR}/guiAnimatedImage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/guiBackgroundImage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/guiBox.cpp @@ -22,8 +25,11 @@ set(gui_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/guiTable.cpp ${CMAKE_CURRENT_SOURCE_DIR}/guiHyperText.cpp ${CMAKE_CURRENT_SOURCE_DIR}/guiVolumeChange.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modalMenu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/profilergraph.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/texture.cpp ${CMAKE_CURRENT_SOURCE_DIR}/touchscreengui.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/window.cpp PARENT_SCOPE ) diff --git a/src/gui/box.cpp b/src/gui/box.cpp new file mode 100644 index 000000000..4c2cdca2a --- /dev/null +++ b/src/gui/box.cpp @@ -0,0 +1,452 @@ +/* +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. +*/ + +#include "gui/box.h" + +#include "debug.h" +#include "log.h" +#include "porting.h" +#include "gui/elem.h" +#include "gui/manager.h" +#include "gui/window.h" +#include "util/serialize.h" + +namespace ui +{ + Align toAlign(u8 align) + { + if (align >= (u8)Align::MAX_ALIGN) { + return Align::CENTER; + } + return (Align)align; + } + + void Layer::reset() + { + image = Texture(); + fill = BLANK; + tint = WHITE; + + source = rf32(0.0f, 0.0f, 1.0f, 1.0f); + middle = rf32(0.0f, 0.0f, 0.0f, 0.0f); + middle_scale = 1.0f; + + num_frames = 1; + frame_time = 1000; + } + + void Layer::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + image = g_manager.getTexture(readNullStr(is)); + if (testShift(set_mask)) + fill = readARGB8(is); + if (testShift(set_mask)) + tint = readARGB8(is); + + if (testShift(set_mask)) { + source.UpperLeftCorner = readV2F32(is); + source.LowerRightCorner = readV2F32(is); + } + if (testShift(set_mask)) { + middle.UpperLeftCorner = clamp_vec(readV2F32(is)); + middle.LowerRightCorner = clamp_vec(readV2F32(is)); + } + if (testShift(set_mask)) + middle_scale = std::max(readF32(is), 0.0f); + + if (testShift(set_mask)) + num_frames = std::max(readU32(is), 1U); + if (testShift(set_mask)) + frame_time = std::max(readU32(is), 1U); + } + + void Style::reset() + { + size = d2f32(0.0f, 0.0f); + + rel_pos = v2f32(0.0f, 0.0f); + rel_anchor = v2f32(0.0f, 0.0f); + rel_size = d2s32(1.0f, 1.0f); + + margin = rf32(0.0f, 0.0f, 0.0f, 0.0f); + padding = rf32(0.0f, 0.0f, 0.0f, 0.0f); + + bg.reset(); + fg.reset(); + + fg_scale = 1.0f; + fg_halign = Align::CENTER; + fg_valign = Align::CENTER; + + visible = true; + noclip = false; + } + + void Style::read(std::istream &is) + { + // No need to read a size prefix; styles are already read in as size- + // prefixed strings in Window. + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + size = clamp_vec(readV2F32(is)); + + if (testShift(set_mask)) + rel_pos = readV2F32(is); + if (testShift(set_mask)) + rel_anchor = readV2F32(is); + if (testShift(set_mask)) + rel_size = clamp_vec(readV2F32(is)); + + if (testShift(set_mask)) { + margin.UpperLeftCorner = readV2F32(is); + margin.LowerRightCorner = readV2F32(is); + } + if (testShift(set_mask)) { + padding.UpperLeftCorner = readV2F32(is); + padding.LowerRightCorner = readV2F32(is); + } + + if (testShift(set_mask)) + bg.read(is); + if (testShift(set_mask)) + fg.read(is); + + if (testShift(set_mask)) + fg_scale = std::max(readF32(is), 0.0f); + if (testShift(set_mask)) + fg_halign = toAlign(readU8(is)); + if (testShift(set_mask)) + fg_valign = toAlign(readU8(is)); + + if (testShift(set_mask)) + visible = testShift(set_mask); + if (testShift(set_mask)) + noclip = testShift(set_mask); + } + + Window &Box::getWindow() + { + return m_elem.getWindow(); + } + + const Window &Box::getWindow() const + { + return m_elem.getWindow(); + } + + void Box::reset() + { + m_style.reset(); + + for (State i = 0; i < m_style_refs.size(); i++) { + m_style_refs[i] = NO_STYLE; + } + + m_draw_rect = rf32(0.0f, 0.0f, 0.0f, 0.0f); + m_child_rect = m_draw_rect; + m_clip_rect = m_draw_rect; + } + + void Box::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 style_mask = readU32(is); + + for (State i = 0; i < m_style_refs.size(); i++) { + // If we have a style for this state in the mask, add it to the + // list of styles. + if (!testShift(style_mask)) { + continue; + } + + u32 style = readU32(is); + if (getWindow().getStyleStr(style) != nullptr) { + m_style_refs[i] = style; + } else { + errorstream << "Style " << style << " does not exist" << std::endl; + } + } + } + + void Box::layout(const rf32 &parent_rect, const rf32 &parent_clip) + { + // Before we layout the box, we need to recompute the style so we have + // fully updated style properties. + computeStyle(); + + // First, calculate the size of the box in absolute coordinates based + // on the normalized size. + d2f32 origin_size( + (m_style.rel_size.Width * parent_rect.getWidth()), + (m_style.rel_size.Height * parent_rect.getHeight()) + ); + + // Ensure that the normalized size of the box isn't smaller than the + // minimum size. + origin_size.Width = std::max(origin_size.Width, m_style.size.Width); + origin_size.Height = std::max(origin_size.Height, m_style.size.Height); + + // Then, create the rect of the box relative to the origin by + // converting the normalized position absolute coordinates, while + // accounting for the anchor based on the previously calculated size. + v2f32 origin_pos( + (m_style.rel_pos.X * parent_rect.getWidth()) - + (m_style.rel_anchor.X * origin_size.Width), + (m_style.rel_pos.Y * parent_rect.getHeight()) - + (m_style.rel_anchor.Y * origin_size.Height) + ); + + rf32 origin_rect(origin_pos, origin_size); + + // The absolute rect of the box is made by shifting the origin to the + // top left of the parent rect. + rf32 abs_rect = origin_rect + parent_rect.UpperLeftCorner; + + // The rect we draw to is the absolute rect adjusted for the margins. + // Since this is the final rect, we ensure that it doesn't have a + // negative size. + m_draw_rect = clamp_rect(rf32( + abs_rect.UpperLeftCorner + m_style.margin.UpperLeftCorner, + abs_rect.LowerRightCorner - m_style.margin.LowerRightCorner + )); + + // The rect that children and the foreground layer are drawn relative + // to is the draw rect adjusted for padding. Make sure this rect is + // never negative as well. + m_child_rect = clamp_rect(rf32( + m_draw_rect.UpperLeftCorner + m_style.padding.UpperLeftCorner, + m_draw_rect.LowerRightCorner - m_style.padding.LowerRightCorner + )); + + // If we are set to noclip, we clip to the same rect we draw to. + // Otherwise, the clip rect is the drawing rect clipped against the + // parent clip rect. + m_clip_rect = m_style.noclip ? m_draw_rect : clip_rect(m_draw_rect, parent_clip); + } + + void Box::draw(Canvas &parent) + { + // Since layout() is always called before draw(), we already have fully + // updated style properties. + + // Don't draw anything if we aren't visible. + if (!m_style.visible) { + return; + } + + // Create a new canvas relative to our parent to draw to. + Canvas canvas(parent, m_style.noclip ? nullptr : &m_clip_rect); + + // Draw our background and foreground layers. + drawLayer(canvas, m_style.bg, m_draw_rect); + drawForeground(canvas); + } + + void Box::drawForeground(Canvas &canvas) + { + // It makes no sense to draw a foreground when there's no image, since + // it would otherwise take no room. + if (!m_style.fg.image.isTexture()) { + return; + } + + // The foreground layer is aligned and scaled in a particular area of + // the box. First, get the size of the foreground layer. + d2f32 src_size = m_style.fg.source.getSize(); + src_size.Height /= m_style.fg.num_frames; + + d2s32 tex_size = m_style.fg.image.getSize(); + src_size.Width *= tex_size.Width; + src_size.Height *= tex_size.Height; + + // Then, compute the scale that we should use. A scale of zero means + // the image should take up as much room as possible while still + // preserving the aspect ratio of the image. + float scale = m_style.fg_scale; + + if (scale == 0.0f) { + scale = std::min( + m_child_rect.getWidth() / src_size.Width, + m_child_rect.getHeight() / src_size.Height + ); + } + + d2f32 fg_size(src_size.Width * scale, src_size.Height * scale); + + // Now, using the alignment options, position the foreground image + // inside the remaining space. + v2f32 fg_pos = m_child_rect.UpperLeftCorner; + + if (m_style.fg_halign == Align::CENTER) { + fg_pos.X += (m_child_rect.getWidth() - fg_size.Width) / 2.0f; + } else if (m_style.fg_halign == Align::END) { + fg_pos.X += m_child_rect.getWidth() - fg_size.Width; + } + + if (m_style.fg_valign == Align::CENTER) { + fg_pos.Y += (m_child_rect.getHeight() - fg_size.Height) / 2.0f; + } else if (m_style.fg_valign == Align::END) { + fg_pos.Y += m_child_rect.getHeight() - fg_size.Height; + } + + // We have our position and size, so now we can draw the layer. + drawLayer(canvas, m_style.fg, rf32(fg_pos, fg_size)); + } + + void Box::drawLayer(Canvas &canvas, const Layer &layer, const rf32 &dst) + { + // Draw the fill color if it's not totally transparent. + if (layer.fill.getAlpha() != 0x0) { + canvas.drawRect(dst, layer.fill); + } + + // If there's no image, there's nothing else for us to do. + if (!layer.image.isTexture()) { + return; + } + + // If we have animations, we need to adjust the source rect by the + // frame offset in accordance with the current frame. + rf32 src = layer.source; + + if (layer.num_frames > 1) { + float frame_height = src.getHeight() / layer.num_frames; + src.LowerRightCorner.Y = src.UpperLeftCorner.Y + frame_height; + + float frame_offset = frame_height * + ((porting::getTimeMs() / layer.frame_time) % layer.num_frames); + src.UpperLeftCorner.Y += frame_offset; + src.LowerRightCorner.Y += frame_offset; + } + + // If the source rect for this image is flipped, we need to flip the + // sign of our middle rect as well to get the right adjustments. + rf32 src_middle = layer.middle; + + if (src.getWidth() < 0.0f) { + src_middle.UpperLeftCorner.X = -src_middle.UpperLeftCorner.X; + src_middle.LowerRightCorner.X = -src_middle.LowerRightCorner.X; + } + if (src.getHeight() < 0.0f) { + src_middle.UpperLeftCorner.Y = -src_middle.UpperLeftCorner.Y; + src_middle.LowerRightCorner.Y = -src_middle.LowerRightCorner.Y; + } + + // Now we need to draw the texture as a nine-slice image. But first, + // since the middle rect uses normalized coordinates, we need to + // de-normalize it into actual pixels for the destination rect and + // scale it by the middle rect scaling parameter. + rf32 scaled_middle( + layer.middle.UpperLeftCorner.X * layer.middle_scale * layer.image.getWidth(), + layer.middle.UpperLeftCorner.Y * layer.middle_scale * layer.image.getHeight(), + layer.middle.LowerRightCorner.X * layer.middle_scale * layer.image.getWidth(), + layer.middle.LowerRightCorner.Y * layer.middle_scale * layer.image.getHeight() + ); + + // Now draw each slice of the nine-slice image. If the middle rect + // equals the whole source rect, this will automatically act like a + // normal image. + for (int y = 0; y < 3; y++) { + for (int x = 0; x < 3; x++) { + rf32 slice_src = src; + rf32 slice_dst = dst; + + switch (x) { + case 0: + slice_dst.LowerRightCorner.X = + dst.UpperLeftCorner.X + scaled_middle.UpperLeftCorner.X; + slice_src.LowerRightCorner.X = + src.UpperLeftCorner.X + src_middle.UpperLeftCorner.X; + break; + + case 1: + slice_dst.UpperLeftCorner.X += scaled_middle.UpperLeftCorner.X; + slice_dst.LowerRightCorner.X -= scaled_middle.LowerRightCorner.X; + slice_src.UpperLeftCorner.X += src_middle.UpperLeftCorner.X; + slice_src.LowerRightCorner.X -= src_middle.LowerRightCorner.X; + break; + + case 2: + slice_dst.UpperLeftCorner.X = + dst.LowerRightCorner.X - scaled_middle.LowerRightCorner.X; + slice_src.UpperLeftCorner.X = + src.LowerRightCorner.X - src_middle.LowerRightCorner.X; + break; + } + + switch (y) { + case 0: + slice_dst.LowerRightCorner.Y = + dst.UpperLeftCorner.Y + scaled_middle.UpperLeftCorner.Y; + slice_src.LowerRightCorner.Y = + src.UpperLeftCorner.Y + src_middle.UpperLeftCorner.Y; + break; + + case 1: + slice_dst.UpperLeftCorner.Y += scaled_middle.UpperLeftCorner.Y; + slice_dst.LowerRightCorner.Y -= scaled_middle.LowerRightCorner.Y; + slice_src.UpperLeftCorner.Y += src_middle.UpperLeftCorner.Y; + slice_src.LowerRightCorner.Y -= src_middle.LowerRightCorner.Y; + break; + + case 2: + slice_dst.UpperLeftCorner.Y = + dst.LowerRightCorner.Y - scaled_middle.LowerRightCorner.Y; + slice_src.UpperLeftCorner.Y = + src.LowerRightCorner.Y - src_middle.LowerRightCorner.Y; + break; + } + + // Draw this slice of the texture with the proper tint. + canvas.drawTexture(slice_dst, layer.image, slice_src, layer.tint); + } + } + } + + void Box::computeStyle() + { + // First, clear our current style and compute what state we're in. + m_style.reset(); + State state = STATE_NONE; + + // Loop over each style state from lowest precedence to highest since + // they should be applied in that order. + for (State i = 0; i < m_style_refs.size(); i++) { + // If this state we're looking at is a subset of the current state, + // then it's a match for styling. + if ((state & i) != i) { + continue; + } + + u32 index = m_style_refs[i]; + + // If the index for this state has an associated style string, + // apply it to our current style. + if (index != NO_STYLE) { + auto is = newIs(*getWindow().getStyleStr(index)); + m_style.read(is); + } + } + } +} diff --git a/src/gui/box.h b/src/gui/box.h new file mode 100644 index 000000000..982fc3d6a --- /dev/null +++ b/src/gui/box.h @@ -0,0 +1,163 @@ +/* +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. +*/ + +#pragma once + +#include "irrlichttypes_extrabloated.h" +#include "gui/texture.h" +#include "util/basic_macros.h" + +#include +#include +#include + +namespace ui +{ + // Serialized enum; do not change order of entries. + enum class Align + { + START, + CENTER, + END, + + MAX_ALIGN, + }; + + Align toAlign(u8 align); + + struct Layer + { + Texture image; + video::SColor fill; + video::SColor tint; + + rf32 source; + rf32 middle; + float middle_scale; + + u32 num_frames; + u32 frame_time; + + Layer() + { + reset(); + } + + void reset(); + void read(std::istream &is); + }; + + struct Style + { + d2f32 size; + + v2f32 rel_pos; + v2f32 rel_anchor; + d2f32 rel_size; + + rf32 margin; + rf32 padding; + + Layer bg; + Layer fg; + + float fg_scale; + Align fg_halign; + Align fg_valign; + + bool visible; + bool noclip; + + Style() + { + reset(); + } + + void reset(); + void read(std::istream &is); + }; + + class Elem; + class Window; + + class Box + { + public: + using State = u32; + + // These states are organized in order of precedence. States with a + // larger value will override the styles of states with a lower value. + static constexpr State STATE_NONE = 0; + + static constexpr State STATE_FOCUSED = 1 << 0; + static constexpr State STATE_SELECTED = 1 << 1; + static constexpr State STATE_HOVERED = 1 << 2; + static constexpr State STATE_PRESSED = 1 << 3; + static constexpr State STATE_DISABLED = 1 << 4; + + static constexpr State NUM_STATES = 1 << 5; + + private: + // Indicates that there is no style string for this state combination. + static constexpr u32 NO_STYLE = -1; + + Elem &m_elem; + + Style m_style; + std::array m_style_refs; + + rf32 m_draw_rect; + rf32 m_child_rect; + rf32 m_clip_rect; + + public: + Box(Elem &elem) : + m_elem(elem) + { + reset(); + } + + DISABLE_CLASS_COPY(Box) + ALLOW_CLASS_MOVE(Box) + + Elem &getElem() { return m_elem; } + const Elem &getElem() const { return m_elem; } + + Window &getWindow(); + const Window &getWindow() const; + + const Style &getStyle() const { return m_style; } + + const rf32 &getDrawRect() const { return m_draw_rect; } + const rf32 &getChildRect() const { return m_child_rect; } + const rf32 &getChildClip() const { return m_clip_rect; } + + void reset(); + void read(std::istream &is); + + void layout(const rf32 &parent_rect, const rf32 &parent_clip); + void draw(Canvas &parent); + + private: + void drawForeground(Canvas &canvas); + void drawLayer(Canvas &canvas, const Layer &layer, const rf32 &dst); + + void computeStyle(); + }; +} diff --git a/src/gui/elem.cpp b/src/gui/elem.cpp new file mode 100644 index 000000000..70ba06904 --- /dev/null +++ b/src/gui/elem.cpp @@ -0,0 +1,147 @@ +/* +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. +*/ + +#include "gui/elem.h" + +#include "debug.h" +#include "log.h" +#include "gui/manager.h" +#include "gui/window.h" +#include "util/serialize.h" + +// Include every element header for Elem::create() +#include "gui/generic_elems.h" + +namespace ui +{ + std::unique_ptr Elem::create(Type type, Window &window, std::string id) + { + std::unique_ptr elem = nullptr; + +#define CREATE(name, type) \ + case name: \ + elem = std::make_unique(window, std::move(id)); \ + break + + switch (type) { + CREATE(ELEM, Elem); + CREATE(ROOT, Root); + default: + return nullptr; + } + +#undef CREATE + + // It's a pain to call reset() in the constructor of every single + // element due to how virtual functions work in C++, so we reset + // elements after creating them here. + elem->reset(); + return elem; + } + + Elem::Elem(Window &window, std::string id) : + m_window(window), + m_id(std::move(id)), + m_main_box(*this) + {} + + void Elem::reset() + { + m_order = (size_t)-1; + + m_parent = nullptr; + m_children.clear(); + + m_main_box.reset(); + } + + void Elem::read(std::istream &is) + { + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + readChildren(is); + if (testShift(set_mask)) + m_main_box.read(is); + } + + void Elem::layout(const rf32 &parent_rect, const rf32 &parent_clip) + { + layoutBoxes(parent_rect, parent_clip); + layoutChildren(); + } + + void Elem::drawAll(Canvas &canvas) + { + draw(canvas); + + for (Elem *child : m_children) { + child->drawAll(canvas); + } + } + + void Elem::layoutBoxes(const rf32 &parent_rect, const rf32 &parent_clip) + { + m_main_box.layout(parent_rect, parent_clip); + } + + void Elem::layoutChildren() + { + for (Elem *child : m_children) { + child->layout(m_main_box.getChildRect(), m_main_box.getChildClip()); + } + } + + void Elem::draw(Canvas &canvas) + { + m_main_box.draw(canvas); + } + + void Elem::readChildren(std::istream &is) + { + u32 num_children = readU32(is); + + for (size_t i = 0; i < num_children; i++) { + std::string id = readNullStr(is); + Elem *child = m_window.getElem(id, true); + + if (child == nullptr) { + continue; + } + + /* Check if this child already has a parent before adding it as a + * child. Elements are deserialized in unspecified order rather + * than a prefix order of parents before their children, so + * isolated circular element refrences are still possible. However, + * cycles including the root are impossible, so recursion starting + * with the root element is safe and will always terminate. + */ + if (child->m_parent != nullptr) { + errorstream << "Element \"" << id << "\" already has parent \"" << + child->m_parent->m_id << "\"" << std::endl; + } else if (child == m_window.getRoot()) { + errorstream << "Element \"" << id << + "\" is the root element and cannot have a parent" << std::endl; + } else { + m_children.push_back(child); + child->m_parent = this; + } + } + } +} diff --git a/src/gui/elem.h b/src/gui/elem.h new file mode 100644 index 000000000..2a85a4bc5 --- /dev/null +++ b/src/gui/elem.h @@ -0,0 +1,98 @@ +/* +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. +*/ + +#pragma once + +#include "irrlichttypes_extrabloated.h" +#include "gui/box.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include + +namespace ui +{ + class Window; + + class Elem + { + public: + // Serialized enum; do not change values of entries. + enum Type + { + ELEM = 0x00, + ROOT = 0x01, + }; + + private: + // The window and ID are intrinsic to the element's identity, so they + // are set by the constructor and aren't cleared in reset() or changed + // in read(). + Window &m_window; + std::string m_id; + + size_t m_order; + + Elem *m_parent; + std::vector m_children; + + Box m_main_box; + + public: + static std::unique_ptr create(Type type, Window &window, std::string id); + + Elem(Window &window, std::string id); + + DISABLE_CLASS_COPY(Elem) + ALLOW_CLASS_MOVE(Elem) + + virtual ~Elem() = default; + + Window &getWindow() { return m_window; } + const Window &getWindow() const { return m_window; } + + const std::string &getId() const { return m_id; } + virtual Type getType() const { return ELEM; } + + size_t getOrder() { return m_order; } + void setOrder(size_t order) { m_order = order; } + + Elem *getParent() { return m_parent; } + const std::vector &getChildren() { return m_children; } + + Box &getMainBox() { return m_main_box; } + + virtual void reset(); + virtual void read(std::istream &is); + + void layout(const rf32 &parent_rect, const rf32 &parent_clip); + void drawAll(Canvas &canvas); + + protected: + virtual void layoutBoxes(const rf32 &parent_rect, const rf32 &parent_clip); + virtual void layoutChildren(); + + virtual void draw(Canvas &canvas); + + private: + void readChildren(std::istream &is); + }; +} diff --git a/src/gui/generic_elems.cpp b/src/gui/generic_elems.cpp new file mode 100644 index 000000000..b0efe3348 --- /dev/null +++ b/src/gui/generic_elems.cpp @@ -0,0 +1,58 @@ +/* +Minetest +Copyright (C) 2024 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. +*/ + +#include "gui/generic_elems.h" + +#include "debug.h" +#include "log.h" +#include "gui/manager.h" +#include "util/serialize.h" + +namespace ui +{ + void Root::reset() + { + Elem::reset(); + + m_backdrop_box.reset(); + } + + void Root::read(std::istream &is) + { + auto super = newIs(readStr32(is)); + Elem::read(super); + + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + m_backdrop_box.read(is); + } + + void Root::layoutBoxes(const rf32 &parent_rect, const rf32 &parent_clip) + { + m_backdrop_box.layout(parent_rect, parent_clip); + Elem::layoutBoxes(m_backdrop_box.getChildRect(), m_backdrop_box.getChildClip()); + } + + void Root::draw(Canvas &canvas) + { + m_backdrop_box.draw(canvas); + Elem::draw(canvas); + } +} diff --git a/src/gui/generic_elems.h b/src/gui/generic_elems.h new file mode 100644 index 000000000..e24008e19 --- /dev/null +++ b/src/gui/generic_elems.h @@ -0,0 +1,52 @@ +/* +Minetest +Copyright (C) 2024 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. +*/ + +#pragma once + +#include "irrlichttypes_extrabloated.h" +#include "gui/box.h" +#include "gui/elem.h" + +#include +#include + +namespace ui +{ + class Root : public Elem + { + private: + Box m_backdrop_box; + + public: + Root(Window &window, std::string id) : + Elem(window, std::move(id)), + m_backdrop_box(*this) + {} + + virtual Type getType() const override { return ROOT; } + + virtual void reset() override; + virtual void read(std::istream &is) override; + + protected: + virtual void layoutBoxes(const rf32 &parent_rect, const rf32 &parent_clip); + + virtual void draw(Canvas &canvas); + }; +} diff --git a/src/gui/manager.cpp b/src/gui/manager.cpp new file mode 100644 index 000000000..217565180 --- /dev/null +++ b/src/gui/manager.cpp @@ -0,0 +1,192 @@ +/* +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. +*/ + +#include "gui/manager.h" + +#include "debug.h" +#include "log.h" +#include "settings.h" +#include "client/client.h" +#include "client/renderingengine.h" +#include "client/tile.h" +#include "util/serialize.h" + +namespace ui +{ + bool testShift(u32 &bits) + { + bool test = bits & 1; + bits >>= 1; + return test; + } + + std::string readStr16(std::istream &is) + { + return deSerializeString16(is, true); + } + + std::string readStr32(std::istream &is) + { + return deSerializeString32(is, true); + } + + std::string readNullStr(std::istream &is) + { + std::string str; + std::getline(is, str, '\0'); + return str; + } + + void writeStr16(std::ostream &os, const std::string &str) + { + os << serializeString16(str, true); + } + + void writeStr32(std::ostream &os, const std::string &str) + { + os << serializeString32(str, true); + } + + void writeNullStr(std::ostream &os, const std::string &str) + { + os << str.substr(0, strlen(str.c_str())) << '\0'; + } + + std::istringstream newIs(std::string str) + { + return std::istringstream(std::move(str), std::ios_base::binary); + } + + std::ostringstream newOs() + { + return std::ostringstream(std::ios_base::binary); + } + + Texture Manager::getTexture(const std::string &name) const + { + return Texture(m_client->tsrc()->getTexture(name)); + } + + float Manager::getPixelSize(WindowType type) const + { + if (type == WindowType::GUI || type == WindowType::MESSAGE) { + return m_gui_pixel_size; + } + return m_hud_pixel_size; + } + + d2f32 Manager::getScreenSize(WindowType type) const + { + video::IVideoDriver *driver = RenderingEngine::get_video_driver(); + d2u32 screen_size = driver->getScreenSize(); + + float pixel_size = getPixelSize(type); + + return d2f32( + screen_size.Width / pixel_size, + screen_size.Height / pixel_size + ); + } + + void Manager::reset() + { + m_client = nullptr; + + m_windows.clear(); + } + + void Manager::removeWindow(u64 id) + { + auto it = m_windows.find(id); + if (it == m_windows.end()) { + infostream << "Window " << id << " is already closed" << std::endl; + return; + } + + m_windows.erase(it); + } + + void Manager::receiveMessage(const std::string &data) + { + auto is = newIs(data); + + u32 action = readU8(is); + u64 id = readU64(is); + + switch (action) { + case REOPEN_WINDOW: { + u64 close_id = readU64(is); + removeWindow(close_id); + } + // fallthrough + + case OPEN_WINDOW: { + auto it = m_windows.find(id); + if (it != m_windows.end()) { + infostream << "Window " << id << " is already open" << std::endl; + break; + } + + it = m_windows.emplace(id, Window(id)).first; + it->second.read(is, true); + break; + } + + case UPDATE_WINDOW: { + auto it = m_windows.find(id); + if (it != m_windows.end()) { + it->second.read(is, false); + } else { + infostream << "Window " << id << " does not exist" << std::endl; + } + break; + } + + case CLOSE_WINDOW: + removeWindow(id); + break; + + default: + errorstream << "Invalid manager action: " << action << std::endl; + break; + } + } + + void Manager::preDraw() + { + float base_size = RenderingEngine::getDisplayDensity(); + m_gui_pixel_size = base_size * g_settings->getFloat("gui_scaling"); + m_hud_pixel_size = base_size * g_settings->getFloat("hud_scaling"); + } + + void Manager::drawType(WindowType type) + { + Texture::begin(); + + for (auto &it : m_windows) { + if (it.second.getType() == type) { + it.second.drawAll(); + } + } + + Texture::end(); + } + + Manager g_manager; +} diff --git a/src/gui/manager.h b/src/gui/manager.h new file mode 100644 index 000000000..89ec4904b --- /dev/null +++ b/src/gui/manager.h @@ -0,0 +1,120 @@ +/* +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. +*/ + +#pragma once + +#include "irrlichttypes_extrabloated.h" +#include "gui/texture.h" +#include "gui/window.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include + +class Client; + +namespace ui +{ + // Define a few functions that are particularly useful for UI serialization + // and deserialization. + bool testShift(u32 &bits); + + // The UI purposefully avoids dealing with SerializationError, so it uses + // always uses truncating or null-terminated string functions. Hence, we + // make convenience wrappers around the string functions in "serialize.h". + std::string readStr16(std::istream &is); + std::string readStr32(std::istream &is); + std::string readNullStr(std::istream &is); + + void writeStr16(std::ostream &os, const std::string &str); + void writeStr32(std::ostream &os, const std::string &str); + void writeNullStr(std::ostream &os, const std::string &str); + + // Convenience functions to create new binary string streams. + std::istringstream newIs(std::string str); + std::ostringstream newOs(); + + class Manager + { + public: + // Serialized enum; do not change values of entries. + enum ReceiveAction + { + OPEN_WINDOW = 0x00, + REOPEN_WINDOW = 0x01, + UPDATE_WINDOW = 0x02, + CLOSE_WINDOW = 0x03, + }; + + private: + Client *m_client; + + float m_gui_pixel_size = 0.0f; + float m_hud_pixel_size = 0.0f; + + // Use map rather than unordered_map so that windows are always sorted + // by window ID to make sure that they are drawn in order of creation. + std::map m_windows; + + public: + Manager() + { + reset(); + } + + DISABLE_CLASS_COPY(Manager) + + Client *getClient() const { return m_client; } + void setClient(Client *client) { m_client = client; } + + Texture getTexture(const std::string &name) const; + + float getPixelSize(WindowType type) const; + d2f32 getScreenSize(WindowType type) const; + + void reset(); + void removeWindow(u64 id); + + void receiveMessage(const std::string &data); + + void preDraw(); + void drawType(WindowType type); + }; + + extern Manager g_manager; + + // Inconveniently, we need a way to draw the "gui" window types after the + // chat console but before other GUIs like the key change menu, formspecs, + // etc. So, we inject our own mini Irrlicht element in between. + class GUIManagerElem : public gui::IGUIElement + { + public: + GUIManagerElem(gui::IGUIEnvironment* env, gui::IGUIElement* parent, s32 id) : + gui::IGUIElement(gui::EGUIET_ELEMENT, env, parent, id, rs32()) + {} + + virtual void draw() override + { + g_manager.drawType(ui::WindowType::GUI); + gui::IGUIElement::draw(); + } + }; +} diff --git a/src/gui/texture.cpp b/src/gui/texture.cpp new file mode 100644 index 000000000..2f223b3f4 --- /dev/null +++ b/src/gui/texture.cpp @@ -0,0 +1,255 @@ +/* +Minetest +Copyright (C) 2022 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. +*/ + +#include "gui/texture.h" + +#include "client/renderingengine.h" +#include "debug.h" +#include "log.h" + +namespace ui +{ + static video::IVideoDriver *driver() { + return RenderingEngine::get_video_driver(); + } + + // The current render target. We keep track of this in set_as_target() so + // as to only change render targets when necessary. It is only valid + // between start_drawing() and end_drawing(); otherwise, it's nullptr. + video::ITexture *s_current_target = nullptr; + + static void force_screen_target() + { + // Force-set the render target to the screen, regardless of the current + // value of s_current_target. + driver()->setRenderTarget(nullptr, false, 0); + s_current_target = nullptr; + } + + static void set_as_target(video::ITexture *target, bool clear = false, + video::SColor color = BLANK) + { + // Don't change the render target if it's already set. + if (s_current_target == target) { + return; + } + + // If we want to clear it to a certain color, then we go ahead and + // clear the depth buffer as well. + u16 to_clear = clear ? video::ECBF_ALL : video::ECBF_NONE; + + if (driver()->setRenderTarget(target, to_clear, color)) { + // The call succeeded, so update the current target variable. + s_current_target = target; + } else { + // The call failed, so we probably don't support render targets. + errorstream << "Unable to set render target" << std::endl; + force_screen_target(); + } + } + + void Texture::begin() + { + // Force set the target since we don't know what the target was before, + // so s_current_target could possibly be invalid. + force_screen_target(); + } + + void Texture::end() + { + set_as_target(nullptr); + } + + Texture Texture::screen = {}; + + Texture::Texture(const d2s32 &size) + { + if (driver()->queryFeature(video::EVDF_RENDER_TO_TARGET)) { + m_texture.grab(driver()->addRenderTargetTexture(d2u32(size))); + if (!isTexture()) { + errorstream << "Unable to create render target" << std::endl; + } + + // The default contents of a texture are appear to be unspecified, + // even though some it's usually transparent by default. So, we + // explicitly make it transparent. + drawFill(BLANK); + } + } + + Texture::Texture(video::ITexture *texture) + { + m_texture.grab(texture); + } + + Texture::~Texture() + { + // If the reference count is one, only Irrlicht still holds a reference + // to the texture, so we can remove it from the driver. + if (isTexture() && m_texture.get()->getReferenceCount() == 1) { + driver()->removeTexture(m_texture.get()); + } + } + + d2s32 Texture::getSize() const + { + if (isTexture()) { + return d2s32(m_texture->getOriginalSize()); + } + return d2s32(driver()->getScreenSize()); + } + + void Texture::drawPixel( + const v2s32 &pos, + video::SColor color, + const rs32 *clip) + { + drawRect(rs32(pos, d2s32(0, 0)), color, clip); + } + + void Texture::drawRect( + const rs32 &rect, + video::SColor color, + const rs32 *clip) + { + set_as_target(m_texture.get()); + driver()->draw2DRectangle(color, rect, clip); + } + + void Texture::drawTexture( + const v2s32 &pos, + const Texture &texture, + const rs32 *src, + const rs32 *clip, + video::SColor tint) + { + d2s32 size = (src != nullptr) ? src->getSize() : texture.getSize(); + drawTexture(rs32(pos, size), texture, src, clip, tint); + } + + void Texture::drawTexture( + const rs32 &rect, + const Texture &texture, + const rs32 *src, + const rs32 *clip, + video::SColor tint) + { + if (!texture.isTexture()) { + errorstream << "Can't draw the screen to a texture" << std::endl; + return; + } + if (m_texture.get() == texture.m_texture.get()) { + errorstream << "Can't draw a texture to itself" << std::endl; + return; + } + + set_as_target(m_texture.get()); + + // If we don't have a source rectangle, make it encompass the entire + // texture. + rs32 texture_rect(texture.getSize()); + if (src == nullptr) { + src = &texture_rect; + } + + // All the corners should have the same tint. + video::SColor tints[] = {tint, tint, tint, tint}; + + driver()->draw2DImage( + texture.m_texture.get(), rect, *src, clip, tints, true); + } + + void Texture::drawFill(video::SColor color) + { + if (isTexture()) { + /* There's no normal way to fill a texture with a color; drawing a + * rect will add alpha, not replace it. So, use setRenderTarget() + * to clear it instead. Irrlicht will ignore the call if this + * texture is already the current render target, so we set it to + * the screen first before attempting to clear the texture. + */ + set_as_target(nullptr); + set_as_target(m_texture.get(), true, color); + } else { + // The screen can't have transparency, so make the color opaque and + // draw a rectangle across the entire screen. + color.setAlpha(0xFF); + driver()->draw2DRectangle(color, rs32(getSize())); + } + } + + Canvas::Canvas(Texture &texture, float scale, const rf32 *clip) : + m_texture(texture), + m_scale(scale) + { + if (clip == nullptr) { + m_clip_ptr = nullptr; + } else { + m_clip = rs32( + clip->UpperLeftCorner.X * m_scale, + clip->UpperLeftCorner.Y * m_scale, + clip->LowerRightCorner.X * m_scale, + clip->LowerRightCorner.Y * m_scale + ); + m_clip_ptr = &m_clip; + } + } + + void Canvas::drawRect( + const rf32 &rect, + video::SColor color) + { + m_texture.drawRect(getDrawRect(rect), color, m_clip_ptr); + } + + void Canvas::drawTexture( + const v2f32 &pos, + const Texture &texture, + const rf32 &src, + video::SColor tint) + { + drawTexture(rf32(pos, d2f32(texture.getSize())), texture, src, tint); + } + + void Canvas::drawTexture( + const rf32 &rect, + const Texture &texture, + const rf32 &src, + video::SColor tint) + { + rs32 draw_src( + src.UpperLeftCorner.X * texture.getWidth(), + src.UpperLeftCorner.Y * texture.getHeight(), + src.LowerRightCorner.X * texture.getWidth(), + src.LowerRightCorner.Y * texture.getHeight() + ); + m_texture.drawTexture( + getDrawRect(rect), texture, &draw_src, m_clip_ptr, tint); + } + + rs32 Canvas::getDrawRect(const rf32 &rect) const + { + return rs32( + rect.UpperLeftCorner.X * m_scale, + rect.UpperLeftCorner.Y * m_scale, + rect.LowerRightCorner.X * m_scale, + rect.LowerRightCorner.Y * m_scale + ); + } +} diff --git a/src/gui/texture.h b/src/gui/texture.h new file mode 100644 index 000000000..fc2879515 --- /dev/null +++ b/src/gui/texture.h @@ -0,0 +1,185 @@ +/* +Minetest +Copyright (C) 2022 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. +*/ + +#pragma once + +#include "irr_ptr.h" +#include "irrlichttypes_extrabloated.h" + +namespace ui +{ + using d2s32 = core::dimension2di; + using d2u32 = core::dimension2du; + using d2f32 = core::dimension2df; + + using rs32 = core::recti; + using rf32 = core::rectf; + + const video::SColor BLANK = 0x00000000; + const video::SColor BLACK = 0xFF000000; + const video::SColor WHITE = 0xFFFFFFFF; + + template + core::vector2d clamp_vec(core::vector2d vec) + { + if (vec.X < 0) + vec.X = 0; + if (vec.Y < 0) + vec.Y = 0; + return vec; + } + + template + core::rect clamp_rect(core::rect rect) + { + if (rect.getWidth() < 0) + rect.LowerRightCorner.X = rect.UpperLeftCorner.X; + if (rect.getHeight() < 0) + rect.LowerRightCorner.Y = rect.UpperLeftCorner.Y; + return rect; + } + + template + core::rect clip_rect(core::rect first, const core::rect &second) + { + first.clipAgainst(second); + return first; + } + + template + T &dim_at(core::dimension2d &dim, size_t index) + { + return index == 0 ? dim.Width : dim.Height; + } + + template + const T &dim_at(const core::dimension2d &dim, size_t index) + { + return index == 0 ? dim.Width : dim.Height; + } + + class Texture + { + private: + irr_ptr m_texture = nullptr; + + public: + // These functions must surround any drawing calls to Texture instances + // to ensure proper tracking of render targets. Raw Irrlicht draw calls + // should be used with caution to avoid messing up render targets. + static void begin(); + static void end(); + + static Texture screen; + + Texture() = default; + Texture(video::ITexture *texture); + Texture(const d2s32 &size); + + ~Texture(); + + d2s32 getSize() const; + + s32 getWidth() const { return getSize().Width; } + s32 getHeight() const { return getSize().Height; } + + bool isTexture() const + { + return m_texture.get() != nullptr; + } + + void drawPixel( + const v2s32 &pos, + video::SColor color, + const rs32 *clip = nullptr); + + void drawRect( + const rs32 &rect, + video::SColor color, + const rs32 *clip = nullptr); + + void drawTexture( + const v2s32 &pos, + const Texture &texture, + const rs32 *src = nullptr, + const rs32 *clip = nullptr, + video::SColor tint = WHITE); + + void drawTexture( + const rs32 &rect, + const Texture &texture, + const rs32 *src = nullptr, + const rs32 *clip = nullptr, + video::SColor tint = WHITE); + + void drawFill(video::SColor color); + + friend bool operator==(const Texture &left, const Texture &right) + { + return left.m_texture == right.m_texture; + } + + friend bool operator!=(const Texture &left, const Texture &right) + { + return !(left == right); + } + }; + + class Canvas + { + private: + Texture &m_texture; + + float m_scale; + + rs32 m_clip; + rs32 *m_clip_ptr; + + public: + Canvas(Texture &texture, float scale, const rf32 *clip = nullptr); + + Canvas(Canvas &canvas, const rf32 *clip = nullptr) : + Canvas(canvas.getTexture(), canvas.getScale(), clip) + {} + + Texture &getTexture() { return m_texture; } + const Texture &getTexture() const { return m_texture; } + + float getScale() const { return m_scale; } + + void drawRect( + const rf32 &rect, + video::SColor color); + + void drawTexture( + const v2f32 &pos, + const Texture &texture, + const rf32 &src = rf32(0.0f, 0.0f, 1.0f, 1.0f), + video::SColor tint = WHITE); + + void drawTexture( + const rf32 &rect, + const Texture &texture, + const rf32 &src = rf32(0.0f, 0.0f, 1.0f, 1.0f), + video::SColor tint = WHITE); + + private: + rs32 getDrawRect(const rf32 &rect) const; + }; +} diff --git a/src/gui/window.cpp b/src/gui/window.cpp new file mode 100644 index 000000000..ef3d61953 --- /dev/null +++ b/src/gui/window.cpp @@ -0,0 +1,284 @@ +/* +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. +*/ + +#include "gui/window.h" + +#include "debug.h" +#include "log.h" +#include "settings.h" +#include "client/client.h" +#include "client/renderingengine.h" +#include "client/tile.h" +#include "gui/manager.h" +#include "gui/texture.h" +#include "util/serialize.h" + +namespace ui +{ + WindowType toWindowType(u8 type) + { + if (type >= (u8)WindowType::MAX_TYPE) { + return WindowType::HUD; + } + return (WindowType)type; + } + + Elem *Window::getElem(const std::string &id, bool required) + { + // Empty IDs may be valid values if the element is optional. + if (id.empty() && !required) { + return nullptr; + } + + // If the ID is not empty, then we need to search for an actual + // element. Not finding one means that an error occurred. + auto it = m_elems.find(id); + if (it != m_elems.end()) { + return it->second.get(); + } + + errorstream << "Element \"" << id << "\" does not exist" << std::endl; + return nullptr; + } + + const std::string *Window::getStyleStr(u32 index) const + { + if (index < m_style_strs.size()) { + return &m_style_strs[index]; + } + return nullptr; + } + + void Window::reset() + { + m_elems.clear(); + m_ordered_elems.clear(); + + m_root_elem = nullptr; + + m_style_strs.clear(); + } + + void Window::read(std::istream &is, bool opening) + { + std::unordered_map elem_contents; + readElems(is, elem_contents); + + readRootElem(is); + readStyles(is); + + if (opening) + m_type = toWindowType(readU8(is)); + + // Assuming no earlier step failed, we can proceed to read in all the + // properties. Otherwise, reset the window entirely. + if (m_root_elem != nullptr) { + updateElems(elem_contents); + } else { + reset(); + } + } + + float Window::getPixelSize() const + { + return g_manager.getPixelSize(m_type); + } + + d2f32 Window::getScreenSize() const + { + return g_manager.getScreenSize(m_type); + } + + void Window::drawAll() + { + if (m_root_elem == nullptr) { + return; + } + + rf32 parent_rect(getScreenSize()); + m_root_elem->layout(parent_rect, parent_rect); + + Canvas canvas(Texture::screen, getPixelSize()); + m_root_elem->drawAll(canvas); + } + + void Window::readElems(std::istream &is, + std::unordered_map &elem_contents) + { + // Read in all the new elements and updates to existing elements. + u32 num_elems = readU32(is); + + std::unordered_map> new_elems; + + for (size_t i = 0; i < num_elems; i++) { + u32 type = readU8(is); + std::string id = readNullStr(is); + + // Make sure that elements have valid IDs. If the string has non-ID + // characters in it, though, we don't particularly care. + if (id.empty()) { + errorstream << "Element has empty ID" << std::endl; + continue; + } + + // Each element has a size prefix stating how big the element is. + // This allows new fields to be added to elements without breaking + // compatibility. So, read it in as a string and save it for later. + std::string contents = readStr32(is); + + // If this is a duplicate element, skip it right away. + if (new_elems.find(id) != new_elems.end()) { + errorstream << "Duplicate element \"" << id << "\"" << std::endl; + continue; + } + + /* Now we need to decide whether to create a new element or to + * modify the state of an already existing one. This allows + * changing attributes of an element (like the style or the + * element's children) while leaving leaving persistent state + * intact (such as the position of a scrollbar or the contents of a + * text field). + */ + std::unique_ptr elem = nullptr; + + // Search for a pre-existing element. + auto it = m_elems.find(id); + + if (it == m_elems.end() || it->second->getType() != type) { + // If the element was not found or the existing element has the + // wrong type, create a new element. + elem = Elem::create((Elem::Type)type, *this, id); + + // If we couldn't create the element, the type was invalid. + // Skip this element entirely. + if (elem == nullptr) { + errorstream << "Element \"" << id << "\" has an invalid type: " << + type << std::endl; + continue; + } + } else { + // Otherwise, use the existing element. + elem = std::move(it->second); + } + + // Now that we've gotten our element, reset its contents. + elem->reset(); + + // We need to read in all elements before updating each element, so + // save the element's contents for later. + elem_contents[elem.get()] = contents; + new_elems.emplace(id, std::move(elem)); + } + + // Set these elements as our list of new elements. + m_elems = std::move(new_elems); + + // Clear the ordered elements for now. They will be regenerated later. + m_ordered_elems.clear(); + } + + void Window::readRootElem(std::istream &is) + { + // Get the root element of the window and make sure it's valid. + m_root_elem = getElem(readNullStr(is), true); + + if (m_root_elem == nullptr) { + errorstream << "Window " << m_id << " has no root element" << std::endl; + reset(); + } else if (m_root_elem->getType() != Elem::ROOT) { + errorstream << "Window " << m_id << + " has wrong type for root element" << std::endl; + reset(); + } + } + + void Window::readStyles(std::istream &is) + { + // Styles are stored in their raw binary form; every time a style needs + // to be recalculated, these binary strings can be applied one over the + // other, resulting in automatic cascading styles. + u32 num_styles = readU32(is); + m_style_strs.clear(); + + for (size_t i = 0; i < num_styles; i++) { + m_style_strs.push_back(readStr16(is)); + } + } + + void Window::updateElems(std::unordered_map &elem_contents) + { + // Now that we have a fully updated window, we can update each element + // with its contents. We couldn't do this before because elements need + // to be able to call getElem() and getStyleStr(). + for (auto &contents : elem_contents) { + auto is = newIs(std::move(contents.second)); + contents.first->read(is); + } + + // Check the depth of the element tree; if it's too deep, there's + // potential for stack overflow. + if (!checkTree(m_root_elem, 1)) { + reset(); + return; + } + + // Update the ordering of the elements so we can do iteration rather + // than recursion when searching through the elements in order. + updateElemOrdering(m_root_elem, 0); + } + + bool Window::checkTree(Elem *elem, size_t depth) const + { + if (depth > MAX_TREE_DEPTH) { + errorstream << "Window " << m_id << + " exceeds max tree depth: " << MAX_TREE_DEPTH << std::endl; + return false; + } + + for (Elem *child : elem->getChildren()) { + if (child->getType() == Elem::ROOT) { + errorstream << "Element of root type \"" << child->getId() << + "\" is not root of window" << std::endl; + return false; + } + + if (!checkTree(child, depth + 1)) { + return false; + } + } + + return true; + } + + size_t Window::updateElemOrdering(Elem *elem, size_t order) + { + // The parent gets ordered before its children since the ordering of + // elements follows draw order. + elem->setOrder(order); + m_ordered_elems.push_back(elem); + + for (Elem *child : elem->getChildren()) { + // Order this element's children using the next index after the + // parent, returning the index of the last child element. + order = updateElemOrdering(child, order + 1); + } + + return order; + } +} diff --git a/src/gui/window.h b/src/gui/window.h new file mode 100644 index 000000000..5b51191a2 --- /dev/null +++ b/src/gui/window.h @@ -0,0 +1,102 @@ +/* +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. +*/ + +#pragma once + +#include "irrlichttypes_extrabloated.h" +#include "gui/elem.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include + +namespace ui +{ + // Serialized enum; do not change order of entries. + enum class WindowType + { + BG, + MASK, + HUD, + MESSAGE, + GUI, + FG, + + MAX_TYPE, + }; + + WindowType toWindowType(u8 type); + + class Window + { + private: + static constexpr size_t MAX_TREE_DEPTH = 64; + + u64 m_id; + WindowType m_type = WindowType::GUI; + + std::unordered_map> m_elems; + std::vector m_ordered_elems; + + Elem *m_root_elem; + + std::vector m_style_strs; + + public: + Window(u64 id) : + m_id(id) + { + reset(); + } + + DISABLE_CLASS_COPY(Window) + ALLOW_CLASS_MOVE(Window) + + u64 getId() const { return m_id; } + + WindowType getType() const { return m_type; } + + const std::vector &getElems() { return m_ordered_elems; } + Elem *getElem(const std::string &id, bool required); + + Elem *getRoot() { return m_root_elem; } + + const std::string *getStyleStr(u32 index) const; + + void reset(); + void read(std::istream &is, bool opening); + + float getPixelSize() const; + d2f32 getScreenSize() const; + + void drawAll(); + + private: + void readElems(std::istream &is, + std::unordered_map &elem_contents); + void readRootElem(std::istream &is); + void readStyles(std::istream &is); + + void updateElems(std::unordered_map &elem_contents); + bool checkTree(Elem *elem, size_t depth) const; + size_t updateElemOrdering(Elem *elem, size_t order); + }; +} diff --git a/src/network/clientopcodes.cpp b/src/network/clientopcodes.cpp index d426d3fe7..34e323e4d 100644 --- a/src/network/clientopcodes.cpp +++ b/src/network/clientopcodes.cpp @@ -120,7 +120,7 @@ const ToClientCommandHandler toClientCommandTable[TOCLIENT_NUM_MSG_TYPES] = { "TOCLIENT_SET_MOON", TOCLIENT_STATE_CONNECTED, &Client::handleCommand_HudSetMoon }, // 0x5b { "TOCLIENT_SET_STARS", TOCLIENT_STATE_CONNECTED, &Client::handleCommand_HudSetStars }, // 0x5c { "TOCLIENT_MOVE_PLAYER_REL", TOCLIENT_STATE_CONNECTED, &Client::handleCommand_MovePlayerRel }, // 0x5d, - null_command_handler, + { "TOCLIENT_UI_MESSAGE", TOCLIENT_STATE_CONNECTED, &Client::handleCommand_UiMessage }, // 0x5e, null_command_handler, { "TOCLIENT_SRP_BYTES_S_B", TOCLIENT_STATE_NOT_CONNECTED, &Client::handleCommand_SrpBytesSandB }, // 0x60 { "TOCLIENT_FORMSPEC_PREPEND", TOCLIENT_STATE_CONNECTED, &Client::handleCommand_FormspecPrepend }, // 0x61, diff --git a/src/network/clientpackethandler.cpp b/src/network/clientpackethandler.cpp index 10ecdd34c..bd4120d76 100644 --- a/src/network/clientpackethandler.cpp +++ b/src/network/clientpackethandler.cpp @@ -999,6 +999,17 @@ void Client::handleCommand_ShowFormSpec(NetworkPacket* pkt) m_client_event_queue.push(event); } +void Client::handleCommand_UiMessage(NetworkPacket* pkt) +{ + std::string *data = new std::string(pkt->getString(0), pkt->getSize()); + + ClientEvent *event = new ClientEvent(); + event->type = CE_UI_MESSAGE; + event->ui_message.data = data; + + m_client_event_queue.push(event); +} + void Client::handleCommand_SpawnParticle(NetworkPacket* pkt) { std::string datastring(pkt->getString(0), pkt->getSize()); diff --git a/src/network/networkprotocol.h b/src/network/networkprotocol.h index add80b3b2..b9c43205a 100644 --- a/src/network/networkprotocol.h +++ b/src/network/networkprotocol.h @@ -223,6 +223,7 @@ with this program; if not, write to the Free Software Foundation, Inc., AO_CMD_SET_BONE_POSITION extended Add TOCLIENT_MOVE_PLAYER_REL Move default minimap from client-side C++ to server-side builtin Lua + Add TOCLIENT_UI_MESSAGE [scheduled bump for 5.9.0] */ @@ -811,6 +812,12 @@ enum ToClientCommand : u16 v3f added_pos */ + TOCLIENT_UI_MESSAGE = 0x5e, + /* + Complicated variable-length structure with many optional fields and + length-prefixed data for future compatibility. + */ + TOCLIENT_SRP_BYTES_S_B = 0x60, /* Belonging to AUTH_MECHANISM_SRP. diff --git a/src/network/serveropcodes.cpp b/src/network/serveropcodes.cpp index 1cb413492..a0e43d304 100644 --- a/src/network/serveropcodes.cpp +++ b/src/network/serveropcodes.cpp @@ -220,7 +220,7 @@ const ClientCommandFactory clientCommandFactoryTable[TOCLIENT_NUM_MSG_TYPES] = { "TOCLIENT_SET_MOON", 0, true }, // 0x5b { "TOCLIENT_SET_STARS", 0, true }, // 0x5c { "TOCLIENT_MOVE_PLAYER_REL", 0, true }, // 0x5d - null_command_factory, // 0x5e + { "TOCLIENT_UI_MESSAGE", 0, true }, // 0x5e null_command_factory, // 0x5f { "TOCLIENT_SRP_BYTES_S_B", 0, true }, // 0x60 { "TOCLIENT_FORMSPEC_PREPEND", 0, true }, // 0x61 diff --git a/src/script/lua_api/l_server.cpp b/src/script/lua_api/l_server.cpp index 7fd086910..27d8df57f 100644 --- a/src/script/lua_api/l_server.cpp +++ b/src/script/lua_api/l_server.cpp @@ -436,6 +436,19 @@ int ModApiServer::l_show_formspec(lua_State *L) return 1; } +// send_ui_message(player, data) +int ModApiServer::l_send_ui_message(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + + size_t len; + const char *player = luaL_checkstring(L, 1); + const char *data = luaL_checklstring(L, 2, &len); + + getServer(L)->sendUiMessage(player, data, len); + return 0; +} + // get_current_modname() int ModApiServer::l_get_current_modname(lua_State *L) { @@ -729,6 +742,7 @@ void ModApiServer::Initialize(lua_State *L, int top) API_FCT(chat_send_all); API_FCT(chat_send_player); API_FCT(show_formspec); + API_FCT(send_ui_message); API_FCT(sound_play); API_FCT(sound_stop); API_FCT(sound_fade); diff --git a/src/script/lua_api/l_server.h b/src/script/lua_api/l_server.h index 33dd814b4..ec32ca358 100644 --- a/src/script/lua_api/l_server.h +++ b/src/script/lua_api/l_server.h @@ -67,6 +67,9 @@ private: // show_formspec(playername,formname,formspec) static int l_show_formspec(lua_State *L); + // send_ui_message(player, data) + static int l_send_ui_message(lua_State *L); + // sound_play(spec, parameters) static int l_sound_play(lua_State *L); diff --git a/src/server.cpp b/src/server.cpp index 5973ab555..6d632a94a 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -3338,6 +3338,19 @@ bool Server::showFormspec(const char *playername, const std::string &formspec, return true; } +void Server::sendUiMessage(const char *name, const char *data, size_t len) +{ + RemotePlayer *player = m_env->getPlayer(name); + if (!player) { + return; + } + + NetworkPacket pkt(TOCLIENT_UI_MESSAGE, 0, player->getPeerId()); + pkt.putRawString(data, len); + + Send(&pkt); +} + u32 Server::hudAdd(RemotePlayer *player, HudElement *form) { if (!player) diff --git a/src/server.h b/src/server.h index de7f9931c..a6448e356 100644 --- a/src/server.h +++ b/src/server.h @@ -326,6 +326,8 @@ public: void addShutdownError(const ModError &e); bool showFormspec(const char *name, const std::string &formspec, const std::string &formname); + void sendUiMessage(const char *name, const char *data, size_t len); + Map & getMap() { return m_env->getMap(); } ServerEnvironment & getEnv() { return *m_env; } v3f findSpawnPos(); From f5397ca46ca24549631d00d60c3b361b8fc1f209 Mon Sep 17 00:00:00 2001 From: v-rob Date: Tue, 23 Jan 2024 22:54:45 -0800 Subject: [PATCH 3/3] Create Lua frontend GUI code --- .luacheckrc | 9 +- builtin/common/misc_helpers.lua | 43 +++ builtin/game/init.lua | 2 + builtin/ui/elem.lua | 130 +++++++++ builtin/ui/elem_defs.lua | 34 +++ builtin/ui/init.lua | 29 ++ builtin/ui/selector.lua | 497 ++++++++++++++++++++++++++++++++ builtin/ui/style.lua | 200 +++++++++++++ builtin/ui/util.lua | 91 ++++++ builtin/ui/window.lua | 308 ++++++++++++++++++++ doc/lua_api.md | 46 ++- 11 files changed, 1387 insertions(+), 2 deletions(-) create mode 100644 builtin/ui/elem.lua create mode 100644 builtin/ui/elem_defs.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 fcc04cab3..ba29044ae 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -20,7 +20,14 @@ read_globals = { "PerlinNoise", "PerlinNoiseMap", string = {fields = {"split", "trim"}}, - table = {fields = {"copy", "getn", "indexof", "insert_all"}}, + table = {fields = { + "copy", + "getn", + "indexof", + "insert_all", + "merge", + "shallow_copy", + }}, math = {fields = {"hypot", "round"}}, } diff --git a/builtin/common/misc_helpers.lua b/builtin/common/misc_helpers.lua index 90ac2ae4e..b8612ac27 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 e6a8e800b..46d020cd4 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 000000000..f79997eb3 --- /dev/null +++ b/builtin/ui/elem.lua @@ -0,0 +1,130 @@ +--[[ +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, id_required) + local class = core.class(base) + + class._type = type + class._type_id = type_id + class._id_required = id_required + + ui._elem_types[type] = class + + return class +end + +function ui.derive_elem(base, type) + assert(not ui._elem_types[type], "Derived element name already used") + return ui._new_type(base, type, base._type_id, base._id_required) +end + +ui.Elem = ui._new_type(nil, "elem", 0x00, false) + +ui.Label = ui.derive_elem(ui.Elem, "label") +ui.Image = ui.derive_elem(ui.Elem, "image") + +function ui.Elem:new(props) + if self._id_required then + assert(ui.is_id(props.id), "ID is required for '" .. self._type .. "'") + end + + self._id = props.id or ui.new_id() + self._groups = {} + self._boxes = {main = true} + self._style = props.style or ui.Style{props = props} + + self._children = table.merge(props.children or props) + + -- Set by parent ui.Elem + self._parent = nil + self._index = nil + self._rindex = nil + + -- Set by ui.Window + self._window = nil + + 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 + + for i, child in ipairs(self._children) do + assert(child._parent == nil, "Element already has a parent") + assert(not core.is_instance(child, ui.Root), + "ui.Root can only be a root element") + + child._parent = self + child._index = i + child._rindex = #self._children - i + 1 + 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, self._boxes.main) + + return ui._encode_flags(fl) +end + +function ui.Elem:_encode_box(fl, box) + -- 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/elem_defs.lua b/builtin/ui/elem_defs.lua new file mode 100644 index 000000000..75e32fd49 --- /dev/null +++ b/builtin/ui/elem_defs.lua @@ -0,0 +1,34 @@ +--[[ +Minetest +Copyright (C) 2024 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.Root = ui._new_type(ui.Elem, "root", 0x01, false) + +function ui.Root:new(props) + ui.Elem.new(self, props) + + self._boxes.backdrop = true +end + +function ui.Root:_encode_fields() + local fl = ui._make_flags() + + self:_encode_box(fl, self._boxes.backdrop) + + return ui._encode("SZ", ui.Elem._encode_fields(self), ui._encode_flags(fl)) +end diff --git a/builtin/ui/init.lua b/builtin/ui/init.lua new file mode 100644 index 000000000..164e8bcdd --- /dev/null +++ b/builtin/ui/init.lua @@ -0,0 +1,29 @@ +--[[ +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") +dofile(UI_PATH .. "elem_defs.lua") diff --git a/builtin/ui/selector.lua b/builtin/ui/selector.lua new file mode 100644 index 000000000..0219cdab7 --- /dev/null +++ b/builtin/ui/selector.lua @@ -0,0 +1,497 @@ +--[[ +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 + +--[[ +Selector parsing functions return a function. When called with an element as +the solitary parameter, this function will return a boolean, indicating whether +the element is matched by the selector. If the boolean is true, a table of +tables {box=..., states=...} is also returned. If false, this is nil. + +The keys of this table are hashes of the box, which serve to prevent duplicate +box/state combos from being generated. The values contain all the combinations +of boxes and states that the selector specifies. The box may be nil if the +selector specified no box, in which case it should default to "main". This list +may also be empty, which means that contradictory boxes were specified and no +box should be styled. The list will not contain duplicates. +--]] + +-- By default, most selectors leave the box unspecified and don't select any +-- particular state, leaving the state at zero. +local function make_box(name, states) + return {name = name, states = states or ui._STATE_NONE} +end + +-- Hash the box to string that represents that combination of box and states +-- uniquely to prevent duplicates in box tables. +local function hash_box(box) + return (box.name or "") .. "$" .. tostring(box.states) +end + +local function make_hashed(name, states) + local box = make_box(name, states) + return {[hash_box(box)] = box} +end + +local function result(matches, name, states) + if matches then + return true, make_hashed(name, states) + end + return false, nil +end + +local simple_preds = { + ["empty"] = function(elem) + return result(#elem._children == 0) + end, + + ["first_child"] = function(elem) + return result(elem._parent == nil or elem._index == 1) + end, + + ["last_child"] = function(elem) + return result(elem._parent == nil or elem._rindex == 1) + end, + + ["only_child"] = function(elem) + return result(elem._parent == nil or #elem._parent._children == 1) + end, +} + +local sel_preds = { + ["<"] = function(sel) + return function(elem) + return result(elem._parent and sel(elem._parent)) + end + end, + + [">"] = function(sel) + return function(elem) + for _, child in ipairs(elem._children) do + if sel(child) then + return result(true) + end + end + return result(false) + end + end, + + ["<<"] = function(sel) + return function(elem) + local ancestor = elem._parent + + while ancestor ~= nil do + if sel(ancestor) then + return result(true) + end + ancestor = ancestor._parent + end + + return result(false) + end + end, + + [">>"] = function(sel) + return function(elem) + for _, descendant in ipairs(elem:_get_flat()) do + if descendant ~= elem and sel(descendant) then + return result(true) + end + end + return result(false) + end + end, + + ["<>"] = function(sel) + return function(elem) + if not elem._parent then + return result(false) + end + + for _, sibling in ipairs(elem._parent._children) do + if sibling ~= elem and sel(sibling) then + return result(true) + end + end + + return result(false) + end + end, +} + +local special_preds = { + ["nth_child"] = function(str) + local index = tonumber(str) + assert(index, "Expected number for ?nth_child") + + return function(elem) + if not elem._parent then + return result(index == 1) + end + return result(elem._index == index) + end + end, + + ["nth_last_child"] = function(str) + local rindex = tonumber(str) + assert(rindex, "Expected number for ?nth_last_child") + + return function(elem) + if not elem._parent then + return result(rindex == 1) + end + return result(elem._rindex == rindex) + end + 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_term(str, pred) + str = str:trim() + assert(str ~= "", "Expected selector term") + + -- We need to test the first character to see what sort of term we're + -- dealing with, and then usually parse from the rest of the string. + local prefix = str:sub(1, 1) + local suffix = str:sub(2) + + if prefix == "*" then + -- Universal terms match everything and have no extra stuff to parse. + return suffix, function(elem) + return result(true) + end + + elseif prefix == "#" then + -- Most selectors are similar to the ID selector, in that characters + -- for the ID string are parsed, and all the characters directly + -- afterwards are returned as the rest of the string after the term. + local id, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(id, "Expected ID after '#'") + + return rest, function(elem) + return result(elem._id == id) + end + + elseif prefix == "." then + local group, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(group, "Expected group after '.'") + + return rest, function(elem) + return result(elem._groups[group] ~= nil) + end + + elseif prefix == "@" then + --[[ + It's possible to check if a box exists in a predicate, but that leads + to different behaviors inside and outside of predicates. @main@thumb + effectively matches nothing by returning an empty table of boxes, but + will return true for scrollbars, which a predicate will interpret as + matching something. So, prevent it altogether. This problem + fundamentally exists because we select elements, not boxes, since boxes + and states are very much tied to the client-side of things. + --]] + assert(not pred, "Box selectors are invalid for predicates") + + local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(name, "Expected box after '@'") + + return rest, function(elem) + if elem._boxes[name] then + -- If the box is in the element, return it. + return result(true, name, ui._STATE_NONE) + elseif name == "all" then + -- If we want all boxes, iterate over the boxes in the element + -- and add each of them to a full list of boxes. + local boxes = {} + + for name in pairs(elem._boxes) do + local box = make_box(name, ui._STATE_NONE) + boxes[hash_box(box)] = box + end + + return true, boxes + end + + -- Otherwise, the selector doesn't match. + return result(false) + end + + elseif prefix == "$" then + -- Unfortunately, we can't detect the state of boxes from the server, + -- so we can't use them in predicates. + assert(not pred, "Style selectors are invalid for predicates") + + local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(name, "Expected state after '$'") + + local state = states_by_name[name] + assert(state, "Invalid state: '" .. name .. "'") + + return rest, function(elem) + -- States unconditionally match every element. Specify the state + -- that this term indicates but leave the box undefined. + return result(true, nil, state) + end + + elseif prefix == "/" then + local type, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)%/(.*)$") + assert(type, "Expected window type after '/'") + + assert(ui._window_types[type], "Invalid window type: '" .. type .. "'") + + return rest, function(elem) + return result(elem._window._type == type) + end + + elseif prefix == "," then + -- Since we don't know which terms came directly behind us, we return + -- nil so that ui._parse_sel() can union the two selectors on either + -- side of the comma instead of returning a selector function. + return suffix, nil + + elseif prefix == "(" then + -- Parse a matching set of parentheses, and recursively pass the + -- contents into ui._parse_sel(). + local sub, rest = str:match("^(%b())(.*)$") + assert(sub, "Unmatched ')' for '('") + + return rest, ui._parse_sel(sub:sub(2, -2), pred) + + elseif prefix == "!" then + -- Parse a single predicate term (NOT an entire predicate selector) and + -- ensure that it's a valid selector term, not a comma. + local rest, term = parse_term(suffix, true) + assert(term, "Expected selector term after '!'") + + return rest, function(elem) + return result(not term(elem)) + end + + elseif prefix == "?" then + -- Predicates may have different syntax depending on the name of the + -- predicate, so just parse the name initially. + local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "%<%>%^]+)(.*)$") + assert(name, "Expected predicate after '?'") + + -- If this is a simple predicate, return its predicate function without + -- doing any further parsing. + local func = simple_preds[name] + if func then + return rest, func + end + + -- If this is a function predicate, we need to do more parsing. + func = sel_preds[name] or special_preds[name] + if func then + -- Parse a matching pair of parentheses and get the contents + -- between them. + assert(rest:sub(1, 1) == "(", "Expected '(' after '?" .. name .. "'") + + local sub, rest = rest:match("^(%b())(.*)$") + assert(sub, "Unmatched ')' for '?" .. name .. "('") + + local contents = sub:sub(2, -2) + + -- If this is a function predicate that wants a selector, parse the + -- contents as a predicate selector and pass it on to the selector + -- creation function. + if sel_preds[name] then + return rest, func(ui._parse_sel(contents, true)) + end + + -- Otherwise, hand the string directly to the function for special + -- processing, which we automatically trim for convenience. + return rest, func(contents:trim()) + end + + -- Otherwise, there is no predicate by this name. + error("Invalid predicate: '?" .. name .. "'") + + else + -- If we found no special character, it's either a type or it indicates + -- invalid characters in the selector string. + local type, rest = str:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(type, "Unexpected character in selector: '" .. prefix .. "'") + + assert(ui._elem_types[type], "Invalid element type: '" .. type .. "'") + + return rest, function(elem) + return result(elem._type == type) + end + end +end + +local function intersect_boxes(a_boxes, b_boxes) + local new_boxes = {} + + for _, box_a in pairs(a_boxes) do + for _, box_b in pairs(b_boxes) do + -- Two boxes can only be merged if they're the same box or if one + -- or both selectors hasn't specified a box yet. + if box_a.name == nil or box_b.name == nil or box_a.name == box_b.name then + -- Create the new box by taking the specified box (if there is + -- one) and ORing the states together (making them more refer + -- to a more specific state). + local new_box = make_box( + box_a.name or box_b.name, + bit.bor(box_a.states, box_b.states) + ) + + -- Hash this box and add it into the table. This will be + -- effectively a no-op if there's already an identical box + -- hashed in the table. + new_boxes[hash_box(new_box)] = new_box + end + end + end + + return new_boxes +end + +function ui._intersect_sels(sels) + return function(elem) + -- We start with the default box, and intersect the box and states from + -- every selector with it. + local all_boxes = make_hashed() + + -- Loop through all of the selectors. All of them need to match for the + -- intersected selector to match. + for _, sel in ipairs(sels) do + local matches, boxes = sel(elem) + if not matches then + -- This selector doesn't match, so fail immediately. + return false, nil + end + + -- Since the selector matched, intersect the boxes and states with + -- those of the other selectors. If two selectors both match an + -- element but specify different boxes, then this selector will + -- return true, but the boxes will be cancelled out in the + -- intersection, leaving an empty list of boxes. + if boxes then + all_boxes = intersect_boxes(all_boxes, boxes) + end + end + + return true, all_boxes + end +end + +local function union_sels(sels) + return function(elem) + -- We initially have no boxes, and have to add them in as matching + -- selectors are unioned in. + local all_boxes = {} + local found_match = false + + -- Loop through all of the selectors. If any of them match, this entire + -- unioned selector matches. + for _, sel in ipairs(sels) do + local matches, boxes = sel(elem) + + if matches then + -- We found a match. However, we can't return true just yet + -- because we need to union the boxes and states from every + -- selector, not just this one. + found_match = true + + if boxes then + -- Add the boxes from this selector into the table of all + -- the boxes. The hashing of boxes will automatically weed + -- out any duplicates. + for hash, box in pairs(boxes) do + all_boxes[hash] = box + end + end + end + end + + if found_match then + return true, all_boxes + end + return false, nil + end +end + +function ui._parse_sel(str, pred) + str = str:trim() + assert(str ~= "", "Empty style selector") + + -- Since selectors consisting of a single universal selector are very + -- common (as a blank ui.Style selector defaults to that), give it a + -- dedicated fast-path and skip all the parsing. + if str == "*" then + return function() + return result(true) + end + end + + local sub_sels = {} + local terms = {} + + -- Loop until we've read every term from the input string. + repeat + -- Parse the next term from the input string. + local term + str, term = parse_term(str, pred) + + if term ~= nil then + -- If we didn't read a comma, insert this term into the list of + -- terms for the current sub-selector. + table.insert(terms, term) + else + -- If we read a comma, make sure that we have terms before and + -- after it so it's not dangling. + assert(#terms > 0, "Expected selector term before ','") + assert(str ~= "", "Expected selector term after ','") + end + + -- If we read a comma or ran out of terms, we need to commit the terms + -- we've read so far. + if term == nil or str == "" then + -- If there's only one term, commit it directly. Otherwise, + -- intersect all the terms together. + if #terms == 1 then + table.insert(sub_sels, terms[1]) + else + table.insert(sub_sels, ui._intersect_sels(terms)) + end + + -- Clear out the list of terms for the next sub-selector. + terms = {} + end + until str == "" + + -- Now that we've read all the sub-selectors between the commas, we need to + -- commit them. We only need to union the terms if there's more than one. + if #sub_sels == 1 then + return sub_sels[1] + end + return union_sels(sub_sels) +end diff --git a/builtin/ui/style.lua b/builtin/ui/style.lua new file mode 100644 index 000000000..24ac6a04e --- /dev/null +++ b/builtin/ui/style.lua @@ -0,0 +1,200 @@ +--[[ +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._sel = ui._parse_sel(props.sel or "*") + self._props = ui._cascade_props(props.props or props, {}) + self._nested = table.merge(props.nested or props) + self._reset = props.reset +end + +function ui.Style:_get_flat() + local flat_styles = {} + self:_get_flat_impl(flat_styles, ui._parse_sel("*")) + return flat_styles +end + +function ui.Style:_get_flat_impl(flat_styles, parent_sel) + -- Intersect our selector with our parent selector, resulting in a fully + -- qualified selector. + local full_sel = ui._intersect_sels({parent_sel, self._sel}) + + -- Copy this style's properties into a new style with the full selector. + local flat = ui.Style{ + reset = self._reset, + props = self._props, + } + flat._sel = full_sel + + 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 styles. + for _, nested in ipairs(self._nested) do + nested:_get_flat_impl(flat_styles, full_sel) + end +end + +local function cascade_layer(new, add, props, p) + new[p.."_image"] = add[p.."_image"] or props[p.."_image"] + new[p.."_fill"] = add[p.."_fill"] or props[p.."_fill"] + new[p.."_tint"] = add[p.."_tint"] or props[p.."_tint"] + + new[p.."_source"] = add[p.."_source"] or props[p.."_source"] + new[p.."_middle"] = add[p.."_middle"] or props[p.."_middle"] + new[p.."_middle_scale"] = add[p.."_middle_scale"] or props[p.."_middle_scale"] + + new[p.."_frames"] = add[p.."_frames"] or props[p.."_frames"] + new[p.."_frame_time"] = add[p.."_frame_time"] or props[p.."_frame_time"] +end + +function ui._cascade_props(add, props) + local new = {} + + new.size = add.size or props.size + + new.rel_pos = add.rel_pos or props.rel_pos + new.rel_anchor = add.rel_anchor or props.rel_anchor + new.rel_size = add.rel_size or props.rel_size + + new.margin = add.margin or props.margin + new.padding = add.padding or props.padding + + cascade_layer(new, add, props, "bg") + cascade_layer(new, add, props, "fg") + + 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 halign_map = {left = 0, center = 1, right = 2} +local valign_map = {top = 0, center = 1, bottom = 2} +local spacing_map = { + before = 0, + after = 1, + outside = 2, + around = 3, + between = 4, + evenly = 5, + remove = 6, +} + +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, "ffff", unpack(props[p.."_source"])) + end + if ui._shift_flag(fl, props[p.."_middle"]) then + ui._encode_flag(fl, "ffff", unpack(props[p.."_middle"])) + end + if ui._shift_flag(fl, props[p.."_middle_scale"]) then + ui._encode_flag(fl, "f", props[p.."_middle_scale"]) + 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 + + return fl +end + +function ui._encode_props(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.size) then + ui._encode_flag(fl, "ff", unpack(props.size)) + end + + if ui._shift_flag(fl, props.rel_pos) then + ui._encode_flag(fl, "ff", unpack(props.rel_pos)) + end + if ui._shift_flag(fl, props.rel_anchor) then + ui._encode_flag(fl, "ff", unpack(props.rel_anchor)) + end + if ui._shift_flag(fl, props.rel_size) then + ui._encode_flag(fl, "ff", unpack(props.rel_size)) + end + + if ui._shift_flag(fl, props.margin) then + ui._encode_flag(fl, "ffff", unpack(props.margin)) + end + if ui._shift_flag(fl, props.padding) then + ui._encode_flag(fl, "ffff", unpack(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, "B", halign_map[props.fg_halign]) + end + if ui._shift_flag(fl, props.fg_valign) then + ui._encode_flag(fl, "B", valign_map[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 000000000..eb6412ced --- /dev/null +++ b/builtin/ui/util.lua @@ -0,0 +1,91 @@ +--[[ +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 + +-- This coordinate size calculation copies the one for fixed-size formspec +-- coordinates in guiFormSpecMenu.cpp. +function ui.get_coord_size() + return math.floor(0.5555 * 96) +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 000000000..bb7204523 --- /dev/null +++ b/builtin/ui/window.lua @@ -0,0 +1,308 @@ +--[[ +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 open_windows = {} + +local function build_window(id, param) + local info = open_windows[id] + if not info then + return nil, nil + end + + local window = info.builder(id, info.player, info.context, param or {}) + assert(core.is_instance(window, ui.Window), + "Expected ui.Window to be returned from builder function") + assert(not window._id, "Window object has already been returned") + + window._id = id + info.window = window + + return window, info.player +end + +ui.Window = core.class() + +ui._window_types = { + bg = 0, + mask = 1, + hud = 2, + message = 3, + gui = 4, + fg = 5, +} + +function ui.Window:new(props) + self._id = nil -- Set by build_window() + self._type = props.type + + self._theme = props.theme or ui.get_default_theme() + self._style = props.style or ui.Style{} + + self._root = props.root + + assert(ui._window_types[self._type], "Invalid window type") + assert(core.is_instance(self._root, ui.Root), + "Expected root of window to be ui.Root") + + self._elems = self._root:_get_flat() + self._elems_by_id = {} + + for _, elem in ipairs(self._elems) do + local id = elem._id + + assert(not self._elems_by_id[id], "Element has duplicate ID: '" .. id .. "'") + self._elems_by_id[id] = elem + + assert(elem._window == nil, "Element already has window") + elem._window = self + end +end + +function ui.Window:_encode(player, opening) + local enc_styles = self:_encode_styles() + local enc_elems = self:_encode_elems() + + local data = ui._encode("ZzZ", enc_elems, self._root._id, enc_styles) + if opening then + data = ui._encode("ZB", data, ui._window_types[self._type]) + end + + return data +end + +function ui.Window:_encode_styles() + -- Clear out all the boxes in every element. + for _, elem in ipairs(self._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():_get_flat() + + -- Take each style and apply its properties to every box and state matched + -- by its selector. + self:_apply_styles(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 = self:_index_styles() + + return ui._encode_array("Z", enc_styles) +end + +function ui.Window:_get_full_style() + -- 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(self._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, + nested = {elem._style}, + }) + end + + -- Return all these styles wrapped up into a single style. + return ui.Style{ + nested = styles, + } +end + +local function apply_style(elem, boxes, style) + -- Loop through each box, applying the styles accordingly. The table of + -- boxes may be empty, in which case nothing happens. + for _, box in pairs(boxes) do + local name = box.name or "main" + + -- If this style resets all properties, find all states that are a + -- subset of the state being styled and clear their property tables. + if style._reset then + for i = ui._STATE_NONE, ui._NUM_STATES - 1 do + if bit.band(box.states, i) == box.states then + elem._boxes[name][i] = nil + end + end + end + + -- Get the existing style property table for this box if it exists. + local props = elem._boxes[name][box.states] or {} + + -- Cascade the properties from this style onto the box. + elem._boxes[name][box.states] = ui._cascade_props(style._props, props) + end +end + +function ui.Window:_apply_styles(styles) + -- Loop through each style and element and see if the style properties can + -- be applied to any boxes. + for _, style in ipairs(styles) do + for _, elem in ipairs(self._elems) do + -- Check if the selector for this style. If it matches, apply the + -- style to each of the applicable boxes. + local matches, boxes = style._sel(elem) + if matches then + apply_style(elem, boxes, style) + end + 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 + +function ui.Window:_index_styles() + local style_indices = {} + local enc_styles = {} + + for _, elem in ipairs(self._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_elems() + local enc_elems = {} + + for _, elem in ipairs(self._elems) do + table.insert(enc_elems, elem:_encode()) + end + + return ui._encode_array("Z", enc_elems) +end + +local OPEN_WINDOW = 0x00 +local REOPEN_WINDOW = 0x01 +local UPDATE_WINDOW = 0x02 +local CLOSE_WINDOW = 0x03 + +local last_id = 0 + +function ui.open(builder, player, context, param) + local id = last_id + last_id = last_id + 1 + + open_windows[id] = { + builder = builder, + player = player, + context = context or {}, + window = nil, -- Set by build_window() + } + + local window = build_window(id, param) + local data = ui._encode("BL Z", OPEN_WINDOW, id, + window:_encode(player, true)) + + core.send_ui_message(player, data) + return id +end + +function ui.reopen(close_id, param) + 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, param) + if not window then + return nil + end + + local data = ui._encode("BLL Z", REOPEN_WINDOW, new_id, close_id, + window:_encode(player, true)) + + core.send_ui_message(player, data) + return new_id +end + +function ui.update(id, param) + local window, player = build_window(id, param) + if not window then + return + end + + local data = ui._encode("BL Z", UPDATE_WINDOW, id, + window:_encode(player, false)) + + core.send_ui_message(player, data) +end + +function ui.close(id) + local info = open_windows[id] + if not info then + return + end + + local data = ui._encode("BL", CLOSE_WINDOW, id) + + core.send_ui_message(info.player, data) + open_windows[id] = nil +end + +function ui.get_window_info(id) + local info = open_windows[id] + if not info then + return nil + end + + -- Only return a subset of the fields that are relevant for the caller. + return { + builder = info.builder, + player = info.player, + context = info.context, + } +end + +function ui.get_open_windows() + local ids = {} + for id in pairs(open_windows) do + table.insert(ids, id) + end + return ids +end diff --git a/doc/lua_api.md b/doc/lua_api.md index 99cae7343..8fd3802cd 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -3961,7 +3961,11 @@ Helper functions * `minetest.get_us_time()` * returns time with microsecond precision. May not return wall time. * `table.copy(table)`: returns a table - * returns a deep copy of `table` + * Returns a deep copy of `table`, i.e. a copy of the table and all its + nested tables. +* `table.shallow_copy(table)`: + * Returns a shallow copy of `table`, i.e. only a copy of the table itself, + but not any of the nested tables. * `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 @@ -3969,6 +3973,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 new single 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. @@ -5465,6 +5472,43 @@ Utilities * `minetest.urlencode(str)`: Encodes reserved 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 a simple example of classes + and inheritance, will print `area=6, filled=true`: + ```lua + local Shape = minetest.class() + function Shape:new(filled) + self.filled = filled + end + + function Shape:describe() + return "area=" .. self:get_area() .. ", filled=" .. tostring(self.filled) + end + + local Rectangle = minetest.class(Shape) + function Rectangle:new(filled, width, height) + Shape.new(self, filled) + + self.width = width + self.height = height + end + + function Rectangle:get_area() + return self.width * self.height + end + + local shape = Rectangle(true, 2, 3) + print(shape:describe()) + ``` +* `minetest.is_instance(obj, class)`: Returns true if and only if `obj` is an + instance of `class` or any of its subclasses. Logging -------