diff --git a/.luacheckrc b/.luacheckrc index 670c84325..2fe7e6915 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -22,7 +22,15 @@ read_globals = { "ValueNoise", "ValueNoiseMap", string = {fields = {"split", "trim"}}, - table = {fields = {"copy", "copy_with_metatables", "getn", "indexof", "keyof", "insert_all"}}, + table = {fields = { + "copy", + "copy_with_metatables", + "getn", + "indexof", + "keyof", + "insert_all", + "merge", + }}, math = {fields = {"hypot", "round"}}, } diff --git a/CMakeLists.txt b/CMakeLists.txt index 70a027f57..7f0afb3db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ set(BUILD_SERVER FALSE CACHE BOOL "Build server") set(BUILD_UNITTESTS TRUE CACHE BOOL "Build unittests") set(BUILD_BENCHMARKS FALSE CACHE BOOL "Build benchmarks") set(BUILD_DOCUMENTATION TRUE CACHE BOOL "Build documentation") +set(BUILD_UI FALSE CACHE BOOL "Build experimental UI API; requires BUILD_CLIENT and USE_SDL2") set(DEFAULT_ENABLE_LTO TRUE) # by default don't enable on Debug builds to get faster builds @@ -77,6 +78,7 @@ message(STATUS "BUILD_SERVER: " ${BUILD_SERVER}) message(STATUS "BUILD_UNITTESTS: " ${BUILD_UNITTESTS}) message(STATUS "BUILD_BENCHMARKS: " ${BUILD_BENCHMARKS}) message(STATUS "BUILD_DOCUMENTATION: " ${BUILD_DOCUMENTATION}) +message(STATUS "BUILD_UI: " ${BUILD_UI}) message(STATUS "RUN_IN_PLACE: " ${RUN_IN_PLACE}) set(WARN_ALL TRUE CACHE BOOL "Enable -Wall for Release build") @@ -398,3 +400,12 @@ if(BUILD_WITH_TRACY) FetchContent_MakeAvailable(tracy) message(STATUS "Fetching Tracy - done") endif() + +if(BUILD_UI) + if(NOT BUILD_CLIENT) + message(FATAL_ERROR "BUILD_UI requires BUILD_CLIENT") + endif() + if(NOT USE_SDL2) + message(FATAL_ERROR "BUILD_UI requires USE_SDL2") + endif() +endif() diff --git a/builtin/common/misc_helpers.lua b/builtin/common/misc_helpers.lua index 29aa3e5c2..717732d53 100644 --- a/builtin/common/misc_helpers.lua +++ b/builtin/common/misc_helpers.lua @@ -579,6 +579,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 @@ -872,3 +881,47 @@ 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 class_try_return(obj, ...) + if select("#", ...) ~= 0 then + return ... + end + return obj +end + +local function class_call(class, ...) + local obj = setmetatable({}, class) + + if obj.new then + return class_try_return(obj, obj:new(...)) + end + + return obj +end + +function core.class(super) + local class = setmetatable({}, {__call = class_call, __index = super}) + class.__index = class + + return class +end + +function core.super(class) + local meta = getmetatable(class) + return meta and meta.__index +end + +function core.is_subclass(class, super) + while class ~= nil do + if class == super then + return true + end + class = core.super(class) + end + + return false +end + +function core.is_instance(obj, class) + return type(obj) == "table" and core.is_subclass(getmetatable(obj), class) +end diff --git a/builtin/game/init.lua b/builtin/game/init.lua index b3c64e729..292975678 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 @@ -39,6 +40,7 @@ dofile(gamepath .. "hud.lua") dofile(gamepath .. "knockback.lua") dofile(gamepath .. "async.lua") dofile(gamepath .. "death_screen.lua") +dofile(uipath .. "init.lua") core.after(0, builtin_shared.cache_content_ids) diff --git a/builtin/ui/clickable_elems.lua b/builtin/ui/clickable_elems.lua new file mode 100644 index 000000000..f2fe71ed5 --- /dev/null +++ b/builtin/ui/clickable_elems.lua @@ -0,0 +1,106 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2024 v-rob, Vincent Robinson + +ui.Button = ui._new_type(ui.Elem, "button", 0x02, true) + +function ui.Button:_init(props) + ui.Elem._init(self, props) + + self._disabled = ui._opt(props.disabled, "boolean") + self._on_press = ui._opt(props.on_press, "function") +end + +function ui.Button:_encode_fields() + local fl = ui._make_flags() + + ui._shift_flag(fl, self._disabled) + ui._shift_flag(fl, self._on_press) + + return ui._encode("SZ", ui.Elem._encode_fields(self), ui._encode_flags(fl)) +end + +ui.Button._handlers[0x00] = function(self, ev, data) + return self._on_press +end + +ui.Toggle = ui._new_type(ui.Elem, "toggle", 0x03, true) + +ui.Check = ui.derive_elem(ui.Toggle, "check") +ui.Switch = ui.derive_elem(ui.Toggle, "switch") + +function ui.Toggle:_init(props) + ui.Elem._init(self, props) + + self._disabled = ui._opt(props.disabled, "boolean") + self._selected = ui._opt(props.selected, "boolean") + + self._on_press = ui._opt(props.on_press, "function") + self._on_change = ui._opt(props.on_change, "function") +end + +function ui.Toggle:_encode_fields() + local fl = ui._make_flags() + + ui._shift_flag(fl, self._disabled) + ui._shift_flag_bool(fl, self._selected) + + ui._shift_flag(fl, self._on_press) + ui._shift_flag(fl, self._on_change) + + return ui._encode("SZ", ui.Elem._encode_fields(self), ui._encode_flags(fl)) +end + +ui.Toggle._handlers[0x00] = function(self, ev, data) + return self._on_press +end + +ui.Toggle._handlers[0x01] = function(self, ev, data) + local selected = ui._decode("B", data) + ev.selected = selected ~= 0 + + return self._on_change +end + +ui.Option = ui._new_type(ui.Elem, "option", 0x04, true) + +ui.Radio = ui.derive_elem(ui.Option, "radio") + +function ui.Option:_init(props) + ui.Elem._init(self, props) + + self._disabled = ui._opt(props.disabled, "boolean") + self._selected = ui._opt(props.selected, "boolean") + + self._family = ui._opt(props.family, "id") + + self._on_press = ui._opt(props.on_press, "function") + self._on_change = ui._opt(props.on_change, "function") +end + +function ui.Option:_encode_fields() + local fl = ui._make_flags() + + ui._shift_flag(fl, self._disabled) + ui._shift_flag_bool(fl, self._selected) + + if ui._shift_flag(fl, self._family) then + ui._encode_flag(fl, "z", self._family) + end + + ui._shift_flag(fl, self._on_press) + ui._shift_flag(fl, self._on_change) + + return ui._encode("SZ", ui.Elem._encode_fields(self), ui._encode_flags(fl)) +end + +ui.Option._handlers[0x00] = function(self, ev, data) + return self._on_press +end + +ui.Option._handlers[0x01] = function(self, ev, data) + local selected = ui._decode("B", data) + ev.selected = selected ~= 0 + + return self._on_change +end diff --git a/builtin/ui/context.lua b/builtin/ui/context.lua new file mode 100644 index 000000000..efcadd2c6 --- /dev/null +++ b/builtin/ui/context.lua @@ -0,0 +1,187 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2025 v-rob, Vincent Robinson + +ui.Context = core.class() + +local open_contexts = {} + +local OPEN_WINDOW = 0x00 +local REOPEN_WINDOW = 0x01 +local UPDATE_WINDOW = 0x02 +local CLOSE_WINDOW = 0x03 + +function ui.Context:new(builder, player, state) + self._builder = ui._req(builder, "function") + self._player = ui._req(player, "string") + self._state = ui._opt(state, "table", {}) + + self._id = nil + self._window = nil +end + +function ui.Context:open(param) + if self:is_open() then + return self + end + + self:_open_window() + self:_build_window(ui._opt(param, "table", {})) + + local data = ui._encode("BL Z", OPEN_WINDOW, self._id, + self._window:_encode(self._player, true)) + + core.send_ui_message(self._player, data) + return self +end + +function ui.Context:reopen(param) + if not self:is_open() then + return self + end + + local close_id = self:_close_window() + self:_open_window() + self:_build_window(ui._opt(param, "table", {})) + + local data = ui._encode("BLL Z", REOPEN_WINDOW, self._id, close_id, + self._window:_encode(self._player, true)) + + core.send_ui_message(self._player, data) + return self +end + +function ui.Context:update(param) + if not self:is_open() then + return self + end + + self:_build_window(ui._opt(param, "table", {})) + + local data = ui._encode("BL Z", UPDATE_WINDOW, self._id, + self._window:_encode(self._player, false)) + + core.send_ui_message(self._player, data) + return self +end + +function ui.Context:close() + if not self:is_open() then + return self + end + + local close_id = self:_close_window() + local data = ui._encode("BL", CLOSE_WINDOW, close_id) + + core.send_ui_message(self._player, data) + return self +end + +function ui.Context:is_open() + return self._id ~= nil +end + +function ui.Context:get_builder() + return self._builder +end + +function ui.Context:get_player() + return self._player +end + +function ui.Context:get_state() + return self._state +end + +function ui.Context:set_state(state) + self._state = ui._req(state, "table") + return self +end + +local last_id = 0 + +function ui.Context:_open_window() + self._id = last_id + last_id = last_id + 1 + + open_contexts[self._id] = self +end + +function ui.Context:_build_window(param) + self._window = self._builder(self, self._player, self._state, param) + + ui._req(self._window, ui.Window) + assert(not self._window._context, "Window object has already been used") + + self._window._context = self +end + +function ui.Context:_close_window() + local close_id = self._id + + self._id = nil + self._window = nil + + open_contexts[close_id] = nil + return close_id +end + +function ui.get_open_contexts() + local contexts = {} + for _, context in pairs(open_contexts) do + table.insert(contexts, context) + end + return contexts +end + +core.register_on_leaveplayer(function(player) + for _, context in pairs(open_contexts) do + if context:get_player() == player:get_player_name() then + context:_close_window() + end + end +end) + +local WINDOW_EVENT = 0x00 +local ELEM_EVENT = 0x01 + +function core.receive_ui_message(player, data) + local action, id, code, rest = ui._decode("BLB Z", data, -1) + + -- Discard events for any window that isn't currently open, since it's + -- probably due to network latency and events coming late. + local context = open_contexts[id] + if not context then + core.log("info", "Window " .. id .. " is not open") + return + end + + -- If the player doesn't match up with what we expected, ignore the + -- (probably malicious) event. + if context:get_player() ~= player then + core.log("action", "Window " .. id .. " has player '" .. context:get_player() .. + "', but received event from player '" .. player .. "'") + return + end + + -- No events should ever fire for non-GUI windows. + if context._window._type ~= "gui" then + core.log("info", "Non-GUI window received event: " .. code) + return + end + + -- Prepare the basic event table shared by all events. + local ev = { + context = context, + player = context:get_player(), + state = context:get_state(), + } + + if action == WINDOW_EVENT then + context._window:_on_window_event(code, ev, rest) + elseif action == ELEM_EVENT then + context._window:_on_elem_event(code, ev, rest) + else + core.log("info", "Invalid window action: " .. action) + end +end diff --git a/builtin/ui/elem.lua b/builtin/ui/elem.lua new file mode 100644 index 000000000..04e201687 --- /dev/null +++ b/builtin/ui/elem.lua @@ -0,0 +1,163 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +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 + class._handlers = setmetatable({}, {__index = base and base._handlers}) + + ui._elem_types[type] = class + + return class +end + +function ui.derive_elem(base, type) + assert(core.is_subclass(base, ui.Elem)) + ui._req(type, "id") + + assert(not ui._elem_types[type], + "Derived element name already used: '" .. type .. "'") + + return ui._new_type(base, type, base._type_id, base._id_required) +end + +ui.Elem = ui._new_type(nil, "elem", 0x00, false) + +function ui.Elem:new(param) + local function make_elem(props) + self:_init(ui._req(props, "table")) + return self + end + + if type(param) == "string" then + self._id = ui._req(param, "id") + return make_elem + end + + self._id = ui.new_id() + return make_elem(param) +end + +function ui.Elem:_init(props) + self._label = ui._opt(props.label, "string") + + self._groups = {} + self._children = {} + + self._props = ui._cascade_props(props.props or props, {}) + self._styles = {} + + -- Set by parent ui.Elem + self._parent = nil + + -- Set by ui.Window + self._boxes = {main = true} + self._window = nil + + if self._id_required then + assert(not ui._is_reserved_id(self._id), + "Element ID is required for '" .. self._type .. "'") + end + + for _, group in ipairs(ui._opt_array(props.groups, "id", {})) do + self._groups[group] = true + end + + for _, child in ipairs(ui._opt_array(props.children, ui.Elem, props)) do + if core.is_instance(child, ui.Elem) then + assert(child._parent == nil, + "Element '" .. child._id .. "' already has a parent") + assert(not core.is_instance(child, ui.Root), + "ui.Root may not be a child element") + + child._parent = self + table.insert(self._children, child) + end + end + + for _, style in ipairs(ui._opt_array(props.styles, ui.Style, props)) do + if core.is_instance(style, ui.Style) then + table.insert(self._styles, style) + end + end + + for _, item in ipairs(props) do + assert(core.is_instance(item, ui.Elem) or core.is_instance(item, ui.Style)) + 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 + + if ui._shift_flag(fl, self._label) then + ui._encode_flag(fl, "s", self._label) + 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 + +function ui.Elem:_on_event(code, ev, data) + -- Get the handler function for this event if we recognize it. + local handler = self._handlers[code] + if not handler then + core.log("info", "Invalid event for " .. self._type_id .. ": " .. code) + return + end + + -- If the event handler returned a callback function for the user, call it + -- with the event table. + local callback = handler(self, ev, data) + if callback then + callback(ev) + end +end diff --git a/builtin/ui/init.lua b/builtin/ui/init.lua new file mode 100644 index 000000000..56f87277b --- /dev/null +++ b/builtin/ui/init.lua @@ -0,0 +1,19 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +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 .. "clickable_elems.lua") +dofile(UI_PATH .. "static_elems.lua") + +dofile(UI_PATH .. "window.lua") +dofile(UI_PATH .. "context.lua") +dofile(UI_PATH .. "theme.lua") diff --git a/builtin/ui/selector.lua b/builtin/ui/selector.lua new file mode 100644 index 000000000..c89b2087c --- /dev/null +++ b/builtin/ui/selector.lua @@ -0,0 +1,582 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +ui._STATE_NONE = 0 +ui._NUM_STATES = bit.lshift(1, 5) +ui._NO_STYLE = -1 + +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), +} + +--[[ +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 unique 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 name may +be nil if the selector specified no box, in which case it will default to +"main" unless/until it is later intersected with a box selector. 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 a 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 + +ui._universal_sel = function() + return result(true) +end + +local simple_preds = {} +local func_preds = {} + +simple_preds["no_children"] = function(elem) + return result(#elem._children == 0) +end + +simple_preds["first_child"] = function(elem) + return result(elem._parent == nil or elem._parent._children[1] == elem) +end + +simple_preds["last_child"] = function(elem) + return result(elem._parent == nil or + elem._parent._children[#elem._parent._children] == elem) +end + +simple_preds["only_child"] = function(elem) + return result(elem._parent == nil or #elem._parent._children == 1) +end + +func_preds["<"] = function(str) + local sel = ui._parse_sel(str, true, false) + + return function(elem) + return result(elem._parent and sel(elem._parent)) + end +end + +func_preds[">"] = function(str) + local sel = ui._parse_sel(str, true, false) + + return function(elem) + for _, child in ipairs(elem._children) do + if sel(child) then + return result(true) + end + end + return result(false) + end +end + +func_preds["<<"] = function(str) + local sel = ui._parse_sel(str, true, false) + + 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 + +func_preds[">>"] = function(str) + local sel = ui._parse_sel(str, true, false) + + 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 + +func_preds["<>"] = function(str) + local sel = ui._parse_sel(str, true, false) + + 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 + +func_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._parent._children[index] == elem) + end +end + +func_preds["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 + + local index = #elem._parent._children - rindex + 1 + return result(elem._parent._children[index] == elem) + end +end + +local function is_nth_match(elem, sel, index, dir) + if not elem._parent then + return index == 1 and sel(elem) + end + + local first, last + if dir == 1 then + first = 1 + last = #elem._parent._children + else + first = #elem._parent._children + last = 1 + end + + local count = 0 + for i = first, last, dir do + local sibling = elem._parent._children[i] + + if sel(sibling) then + count = count + 1 + end + + if count == index then + return sibling == elem + end + end + + return false +end + +func_preds["first_match"] = function(str) + local sel = ui._parse_sel(str, true, false) + + return function(elem) + return is_nth_match(elem, sel, 1, 1) + end +end + +func_preds["last_match"] = function(str) + local sel = ui._parse_sel(str, true, false) + + return function(elem) + return is_nth_match(elem, sel, 1, -1) + end +end + +func_preds["only_match"] = function(str) + local sel = ui._parse_sel(str, true, false) + + return function(elem) + return is_nth_match(elem, sel, 1, 1) and is_nth_match(elem, sel, 1, -1) + end +end + +func_preds["nth_match"] = function(str) + local sel, rest = ui._parse_sel(str, true, true) + local index = tonumber(rest) + assert(index, "Expected number after ';' for ?nth_match()") + + return function(elem) + return is_nth_match(elem, sel, index, 1) + end +end + +func_preds["nth_last_match"] = function(str) + local sel, rest = ui._parse_sel(str, true, true) + local rindex = tonumber(rest) + assert(rindex, "Expected number after ';' for ?nth_last_match()") + + return function(elem) + return is_nth_match(elem, sel, rindex, -1) + end +end + +func_preds["family"] = function(family) + if family == "*" then + return function(elem) + return result(elem._family ~= nil) + end + end + + assert(ui.is_id(family), "Expected '*' or ID string for ?family()") + return function(elem) + return result(elem._family == family) + end +end + +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 ui._universal_sel, suffix, nil + + 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 function(elem) + return result(elem._id == id) + end, rest, nil + + elseif prefix == "%" then + local group, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(group, "Expected group after '%'") + + return function(elem) + return result(elem._groups[group] ~= nil) + end, rest, nil + + 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. For + -- instance, @main@thumb effectively matches nothing by returning an + -- empty table of boxes, but would return true for scrollbars if used + -- in a predicate. So, prevent box selectors in predicates entirely. + assert(not pred, "Box selectors are invalid in predicate selectors") + + -- First, check if this can be parsed as a universal box selector. + local name = suffix:sub(1, 1) + local rest + + if name == "*" then + rest = suffix:sub(2) + + return function(elem) + -- If we want all boxes, iterate over the boxes in the element + -- and add each of them to the 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, rest, nil + end + + -- Otherwise, parse it as a normal box selector instead. + name, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$") + assert(name, "Expected box or '*' after '@'") + + return function(elem) + -- If the box is in the element, return it. Otherwise, the + -- selector doesn't match. + if elem._boxes[name] then + return result(true, name, ui._STATE_NONE) + end + return result(false) + end, rest, nil + + 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, "State selectors are invalid in predicate selectors") + + 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 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, rest, nil + + 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 function(elem) + return result(elem._window._type == type) + end, rest, nil + + elseif prefix == "," or prefix == ";" then + -- Return nil instead of a function and return the prefix character to + -- instruct ui._parse_sel() to union or end the selector accordingly. + return nil, suffix, prefix + + 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 ui._parse_sel(sub:sub(2, -2), pred, false), rest, nil + + 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 or semicolon. + local term, rest, _ = parse_term(suffix, true) + assert(term, "Expected selector term after '!'") + + return function(elem) + return result(not term(elem)) + end, rest, nil + + elseif prefix == "?" then + -- Predicates may have different syntax depending on the name of the + -- predicate, so just parse the name initially. + local name, after = 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 func, after, nil + end + + -- If this is a function predicate, we need to do more parsing. + func = func_preds[name] + if func then + -- Parse a matching pair of parentheses and get the trimmed + -- contents between them. + assert(after:sub(1, 1) == "(", "Expected '(' after '?" .. name .. "'") + + local sub, rest = after:match("^(%b())(.*)$") + assert(sub, "Unmatched ')' for '?" .. name .. "('") + + local contents = sub:sub(2, -2):trim() + return func(contents), rest, nil + 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 '" .. prefix .. "' in selector") + + assert(ui._elem_types[type], "Invalid element type: '" .. type .. "'") + + return function(elem) + return result(elem._type == type) + end, rest, nil + 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 + +function ui._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, partial) + str = str:trim() + assert(str ~= "", "Empty style selector") + + local sub_sels = {} + local terms = {} + local done = false + + -- Loop until we've read every term from the input string. + while not done do + -- Parse the next term from the input string. + local term, prefix + term, str, prefix = parse_term(str, pred) + + -- If we read a term, insert this term into the list of terms for the + -- current sub-selector. + if term then + table.insert(terms, term) + end + + -- Make sure that we have at least one selector term before each comma + -- or semicolon that we read. + if prefix then + assert(#terms > 0, "Expected selector term before '" .. prefix .. "'") + end + + -- If we read a comma or semicolon or have run out of terms, we need to + -- commit the terms we've read so far. + if prefix 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 + + -- If we read a semicolon or have run out of terms, we're done parsing. + -- We check for the semicolon case first since it is possible for the + -- string to be empty after reading the semicolon. + if prefix == ";" then + assert(partial, "Unexpected character ';' in selector") + done = true + elseif str == "" then + assert(prefix ~= ",", "Expected selector term after ','") + assert(not partial, "Expected ';' after end of selector") + done = true + end + end + + -- 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], str:trim() + end + return ui._union_sels(sub_sels), str:trim() +end diff --git a/builtin/ui/static_elems.lua b/builtin/ui/static_elems.lua new file mode 100644 index 000000000..35ffa79c4 --- /dev/null +++ b/builtin/ui/static_elems.lua @@ -0,0 +1,23 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2024 v-rob, Vincent Robinson + +ui.Group = ui.derive_elem(ui.Elem, "group") +ui.Label = ui.derive_elem(ui.Elem, "label") +ui.Image = ui.derive_elem(ui.Elem, "image") + +ui.Root = ui._new_type(ui.Elem, "root", 0x01, false) + +function ui.Root:_init(props) + ui.Elem._init(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/style.lua b/builtin/ui/style.lua new file mode 100644 index 000000000..7297396ee --- /dev/null +++ b/builtin/ui/style.lua @@ -0,0 +1,347 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +ui.Style = core.class() + +function ui.Style:new(param) + local function make_style(props) + self:_init(ui._req(props, "table")) + return self + end + + if type(param) == "string" then + self._sel = ui._parse_sel(param, false, false) + return make_style + end + + self._sel = ui._universal_sel + return make_style(param) +end + +function ui.Style:_init(props) + self._props = ui._cascade_props(props.props or props, {}) + self._nested = table.merge(ui._opt_array(props.nested, ui.Style, props)) + self._reset = ui._opt(props.reset, "boolean") + + for _, item in ipairs(props) do + ui._req(item, ui.Style) + end +end + +function ui.Style:_get_flat() + local flat_styles = {} + self:_get_flat_impl(flat_styles, ui._universal_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 { + props = self._props, + reset = self._reset, + } + 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 layout_type_map = { + place = 0, +} + +local dir_flags_map = { + none = 0, + x = 1, + y = 2, + both = 3, +} + +local display_mode_map = { + visible = 0, + overflow = 1, + hidden = 2, + clipped = 3, +} + +local icon_place_map = { + center = 0, + left = 1, + top = 2, + right = 3, + bottom = 4, +} + +local align_map = { + left = 0, + center = 1, + right = 2, +} + +local valign_map = { + top = 0, + center = 1, + bottom = 2, +} + +local function opt_color(val, def) + assert(val == nil or core.colorspec_to_int(val)) + return val or def +end + +local function opt_vec2d(val, def) + ui._opt_array(val, "number") + assert(val == nil or #val == 1 or #val == 2) + return val or def +end + +local function opt_rect(val, def) + ui._opt_array(val, "number") + assert(val == nil or #val == 1 or #val == 2 or #val == 4) + return val or def +end + +local function cascade_layout(new, add, props) + new.layout = ui._opt_enum(add.layout, layout_type_map, props.layout) + new.clip = ui._opt_enum(add.clip, dir_flags_map, props.clip) + + new.scale = ui._opt(add.scale, "number", props.scale) +end + +local function cascade_sizing(new, add, props) + new.size = opt_vec2d(add.size, props.size) + new.span = opt_vec2d(add.span, props.span) + + new.pos = opt_vec2d(add.pos, props.pos) + new.anchor = opt_vec2d(add.anchor, props.anchor) + + new.margin = opt_rect(add.margin, props.margin) + new.padding = opt_rect(add.padding, props.padding) +end + +local function cascade_layer(new, add, props, p) + new[p.."_image"] = ui._opt(add[p.."_image"], "string", props[p.."_image"]) + new[p.."_fill"] = opt_color(add[p.."_fill"], props[p.."_fill"]) + new[p.."_tint"] = opt_color(add[p.."_tint"], props[p.."_tint"]) + + new[p.."_scale"] = ui._opt(add[p.."_scale"], "number", props[p.."_scale"]) + new[p.."_source"] = opt_rect(add[p.."_source"], props[p.."_source"]) + + new[p.."_frames"] = ui._opt(add[p.."_frames"], "number", props[p.."_frames"]) + new[p.."_frame_time"] = + ui._opt(add[p.."_frame_time"], "number", props[p.."_frame_time"]) +end + +local function cascade_text(new, add, props) + new.prepend = ui._opt(add.prepend, "string", props.prepend) + new.append = ui._opt(add.append, "string", props.append) + + new.text_color = opt_color(add.text_color, props.text_color) + new.text_mark = opt_color(add.text_mark, props.text_mark) + new.text_size = ui._opt(add.text_size, "number", props.text_size) + + new.text_mono = ui._opt(add.text_mono, "boolean", props.text_mono) + new.text_italic = ui._opt(add.text_italic, "boolean", props.text_italic) + new.text_bold = ui._opt(add.text_bold, "boolean", props.text_bold) + + new.text_align = ui._opt_enum(add.text_align, align_map, props.text_align) + new.text_valign = ui._opt_enum(add.text_valign, valign_map, props.text_valign) +end + +function ui._cascade_props(add, props) + local new = {} + + cascade_layout(new, add, props) + cascade_sizing(new, add, props) + + new.display = ui._opt_enum(add.display, display_mode_map, props.display) + + cascade_layer(new, add, props, "box") + cascade_layer(new, add, props, "icon") + + new.box_middle = opt_rect(add.box_middle, props.box_middle) + new.box_tile = ui._opt_enum(add.box_tile, dir_flags_map, props.box_tile) + + new.icon_place = ui._opt_enum(add.icon_place, icon_place_map, props.icon_place) + new.icon_gutter = ui._opt(add.icon_gutter, "number", props.icon_gutter) + new.icon_overlap = ui._opt(add.icon_overlap, "boolean", props.icon_overlap) + + cascade_text(new, add, props) + + return new +end + +local function unpack_vec2d(vec) + if #vec == 2 then + return vec[1], vec[2] + elseif #vec == 1 then + return vec[1], vec[1] + end +end + +local function unpack_rect(rect) + if #rect == 4 then + return rect[1], rect[2], rect[3], rect[4] + elseif #rect == 2 then + return rect[1], rect[2], rect[1], rect[2] + elseif #rect == 1 then + return rect[1], rect[1], rect[1], rect[1] + end +end + +local function encode_layout(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.layout) then + ui._encode_flag(fl, "B", layout_type_map[props.layout]) + end + if ui._shift_flag(fl, props.clip) then + ui._encode_flag(fl, "B", dir_flags_map[props.clip]) + end + + if ui._shift_flag(fl, props.scale) then + ui._encode_flag(fl, "f", props.scale) + end + + return fl +end + +local function encode_sizing(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.size) then + ui._encode_flag(fl, "ff", unpack_vec2d(props.size)) + end + if ui._shift_flag(fl, props.span) then + ui._encode_flag(fl, "ff", unpack_vec2d(props.span)) + end + + if ui._shift_flag(fl, props.pos) then + ui._encode_flag(fl, "ff", unpack_vec2d(props.pos)) + end + if ui._shift_flag(fl, props.anchor) then + ui._encode_flag(fl, "ff", unpack_vec2d(props.anchor)) + end + + if ui._shift_flag(fl, props.margin) then + ui._encode_flag(fl, "ffff", unpack_rect(props.margin)) + end + if ui._shift_flag(fl, props.padding) then + ui._encode_flag(fl, "ffff", unpack_rect(props.padding)) + end + + return fl +end + +local function encode_layer(props, p) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props[p.."_image"]) then + ui._encode_flag(fl, "z", props[p.."_image"]) + end + if ui._shift_flag(fl, props[p.."_fill"]) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props[p.."_fill"])) + end + if ui._shift_flag(fl, props[p.."_tint"]) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props[p.."_tint"])) + end + + if ui._shift_flag(fl, props[p.."_scale"]) then + ui._encode_flag(fl, "f", props[p.."_scale"]) + end + if ui._shift_flag(fl, props[p.."_source"]) then + ui._encode_flag(fl, "ffff", unpack_rect(props[p.."_source"])) + 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 + +local function encode_text(props) + local fl = ui._make_flags() + + if ui._shift_flag(fl, props.prepend) then + ui._encode_flag(fl, "s", props.prepend) + end + if ui._shift_flag(fl, props.append) then + ui._encode_flag(fl, "s", props.append) + end + + if ui._shift_flag(fl, props.text_color) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props.text_color)) + end + if ui._shift_flag(fl, props.text_mark) then + ui._encode_flag(fl, "I", core.colorspec_to_int(props.text_mark)) + end + if ui._shift_flag(fl, props.text_size) then + ui._encode_flag(fl, "I", props.text_size) + end + + ui._shift_flag_bool(fl, props.text_mono) + ui._shift_flag_bool(fl, props.text_italic) + ui._shift_flag_bool(fl, props.text_bold) + + if ui._shift_flag(fl, props.text_align) then + ui._encode_flag(fl, "B", align_map[props.text_align]) + end + if ui._shift_flag(fl, props.text_valign) then + ui._encode_flag(fl, "B", valign_map[props.text_valign]) + end + + return fl +end + +local function encode_subflags(fl, sub_fl) + if ui._shift_flag(fl, sub_fl.flags ~= 0) then + ui._encode_flag(fl, "s", ui._encode_flags(sub_fl)) + end +end + +function ui._encode_props(props) + local fl = ui._make_flags() + + encode_subflags(fl, encode_layout(props)) + encode_subflags(fl, encode_sizing(props)) + + if ui._shift_flag(fl, props.display) then + ui._encode_flag(fl, "B", display_mode_map[props.display]) + end + + encode_subflags(fl, encode_layer(props, "box")) + encode_subflags(fl, encode_layer(props, "icon")) + + if ui._shift_flag(fl, props.box_middle) then + ui._encode_flag(fl, "ffff", unpack_rect(props.box_middle)) + end + if ui._shift_flag(fl, props.box_tile) then + ui._encode_flag(fl, "B", dir_flags_map[props.box_tile]) + end + + if ui._shift_flag(fl, props.icon_place) then + ui._encode_flag(fl, "B", icon_place_map[props.icon_place]) + end + if ui._shift_flag(fl, props.icon_gutter) then + ui._encode_flag(fl, "f", props.icon_gutter) + end + ui._shift_flag_bool(fl, props.icon_overlap) + + encode_subflags(fl, encode_text(props)) + + return ui._encode("s", ui._encode_flags(fl)) +end diff --git a/builtin/ui/theme.lua b/builtin/ui/theme.lua new file mode 100644 index 000000000..1079dd30c --- /dev/null +++ b/builtin/ui/theme.lua @@ -0,0 +1,40 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +local prelude_theme = ui.Style { + ui.Style "root" { + pos = {1/2}, + anchor = {1/2}, + span = {0}, + + ui.Style "@backdrop" { + display = "hidden", + clip = "both", + }, + ui.Style "@backdrop$focused" { + display = "visible", + }, + }, + ui.Style "image" { + icon_scale = 0, + }, + ui.Style "check, switch, radio" { + icon_place = "left", + text_align = "left", + }, +} + +function ui.get_prelude_theme() + return prelude_theme +end + +local default_theme = prelude_theme + +function ui.get_default_theme() + return default_theme +end + +function ui.set_default_theme(theme) + default_theme = ui._req(theme, ui.Style) +end diff --git a/builtin/ui/util.lua b/builtin/ui/util.lua new file mode 100644 index 000000000..25b8a922e --- /dev/null +++ b/builtin/ui/util.lua @@ -0,0 +1,120 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +local next_id = 0 + +function ui.new_id() + -- Just increment a monotonic counter and return it as hex. Even at + -- unreasonably fast ID generation rates, it would take years for this + -- counter to hit the 2^53 limit and start generating duplicates. + next_id = next_id + 1 + return string.format("_%X", next_id) +end + +ui._ID_CHARS = "a-zA-Z0-9_%-%:" + +function ui.is_id(str) + return type(str) == "string" and str == str:match("^[" .. ui._ID_CHARS .. "]+$") +end + +function ui._is_reserved_id(str) + return ui.is_id(str) and str:match("^[_-]") +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._req(val, typ) + assert(type(val) == typ or + (typ == "id" and ui.is_id(val)) or core.is_instance(val, typ)) + return val +end + +function ui._opt(val, typ, def) + if val == nil then + return def + end + return ui._req(val, typ) +end + +function ui._req_array(arr, typ) + for _, val in ipairs(ui._req(arr, "table")) do + ui._req(val, typ) + end + return arr +end + +function ui._opt_array(arr, typ, def) + for _, val in ipairs(ui._opt(arr, "table", {})) do + ui._req(val, typ) + end + return arr or def +end + +function ui._req_enum(val, enum) + assert(type(val) == "string" and enum[val]) + return val +end + +function ui._opt_enum(val, enum, def) + if val == nil then + return def + end + return ui._req_enum(val, enum) +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._shift_flag_bool(fl, flag) + if ui._shift_flag(fl, flag ~= nil) then + ui._shift_flag(fl, flag) + else + ui._shift_flag(fl, false) + end +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..72fc73868 --- /dev/null +++ b/builtin/ui/window.lua @@ -0,0 +1,283 @@ +-- Luanti +-- SPDX-License-Identifier: LGPL-2.1-or-later +-- Copyright (C) 2023 v-rob, Vincent Robinson + +ui.Window = core.class() + +ui._window_types = { + filter = 0, + mask = 1, + hud = 2, + chat = 3, + gui = 4, +} + +function ui.Window:new(param) + local function make_window(props) + self:_init(ui._req(props, "table")) + return self + end + + self._type = ui._req_enum(param, ui._window_types) + return make_window +end + +function ui.Window:_init(props) + self._theme = ui._opt(props.theme, ui.Style, ui.get_default_theme()) + self._styles = ui._opt_array(props.styles, ui.Style, props) + + self._root = ui._req(props.root, ui.Root) + + self._focused = ui._opt(props.focused, "string") + self._allow_close = ui._opt(props.allow_close, "boolean", true) + + self._on_close = ui._opt(props.on_close, "function") + self._on_submit = ui._opt(props.on_submit, "function") + self._on_focus_change = ui._opt(props.on_focus_change, "function") + + self._context = nil -- Set by ui.Context + + 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 '" .. elem._id .. "' already has a window") + elem._window = self + end + + if self._focused and self._focused ~= "" then + assert(self._elems_by_id[self._focused], + "Invalid focused element: '" .. self._focused .. "'") + end + + for _, item in ipairs(props) do + ui._req(item, ui.Style) + end +end + +function ui.Window:_encode(player, opening) + local enc_styles = self:_encode_styles() + local enc_elems = self:_encode_elems() + + local fl = ui._make_flags() + + if ui._shift_flag(fl, self._focused) then + ui._encode_flag(fl, "z", self._focused) + end + ui._shift_flag(fl, opening and self._allow_close) + + ui._shift_flag(fl, self._on_submit) + ui._shift_flag(fl, self._on_focus_change) + + 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 ui._encode("ZZ", data, ui._encode_flags(fl)) +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 styles, and local element + -- styles as sub-styles, in that order, to ensure the correct precedence. + local styles = table.merge({self._theme}, self._styles) + + 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. + local local_style = ui.Style("#" .. elem._id) { + props = elem._props, + nested = elem._styles, + } + table.insert(styles, local_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 + +function ui.Window:_on_window_event(code, ev, data) + -- Get the handler function for this event if we recognize it. + local handler = self._handlers[code] + if not handler then + core.log("info", "Invalid window event: " .. code) + return + end + + -- If the event handler returned a callback function for the user, call it + -- with the event table. + local callback = handler(self, ev, data) + if callback then + callback(ev) + end +end + +function ui.Window:_on_elem_event(code, ev, data) + local type_id, target, rest = ui._decode("BzZ", data, -1) + ev.target = target + + -- Get the element for this ID. If it doesn't exist or has a different + -- type, the window probably updated before receiving this event. + local elem = self._elems_by_id[target] + if not elem then + core.log("info", "Dropped event for non-existent element '" .. target .. "'") + return + elseif elem._type_id ~= type_id then + core.log("info", "Dropped event with type " .. type_id .. + " sent to element with type " .. elem._type_id) + return + end + + -- Pass the event and data to the element for further processing. + elem:_on_event(code, ev, rest) +end + +ui.Window._handlers = {} + +ui.Window._handlers[0x00] = function(self, ev, data) + -- We should never receive an event for an uncloseable window. If we + -- did, this player might be trying to cheat. + if not self._allow_close then + core.log("action", "Player '" .. self._context:get_player() .. + "' closed uncloseable window") + return nil + end + + -- Since the window is now closed, remove the open window data. + self._context:_close_window() + return self._on_close +end + +ui.Window._handlers[0x01] = function(self, ev, data) + return self._on_submit +end + +ui.Window._handlers[0x02] = function(self, ev, data) + ev.unfocused, ev.focused = ui._decode("zz", data) + + -- If the ID for either element doesn't exist, we probably updated the + -- window to remove the element. Assume nothing is focused then. + if not self._elems_by_id[ev.unfocused] then + ev.unfocused = "" + end + if not self._elems_by_id[ev.focused] then + ev.focused = "" + end + + return self._on_focus_change +end diff --git a/doc/compiling/README.md b/doc/compiling/README.md index 9ce8a800e..b3088ef66 100644 --- a/doc/compiling/README.md +++ b/doc/compiling/README.md @@ -14,6 +14,7 @@ General options and their default values: BUILD_UNITTESTS=TRUE - Build unittest sources BUILD_BENCHMARKS=FALSE - Build benchmark sources BUILD_DOCUMENTATION=TRUE - Build doxygen documentation + BUILD_UI=TRUE - Build experimental UI API; requires BUILD_CLIENT and USE_SDL2 CMAKE_BUILD_TYPE=Release - Type of build (Release vs. Debug) Release - Release build Debug - Debug build diff --git a/doc/experimental_ui_api.md b/doc/experimental_ui_api.md new file mode 100644 index 000000000..9efd2e8f8 --- /dev/null +++ b/doc/experimental_ui_api.md @@ -0,0 +1,2087 @@ +UI API +====== + +The UI API is the unified replacement for the older formspec and player HUD +systems, exposing a new system that is simpler, more robust and powerful, while +additionally being less buggy and quirky than its predecessors. It is not yet +stable, and feedback is encouraged. + +**Warning**: The UI API is entirely experimental and may only be used for +testing purposes, not for stable mods. The API can and will change without +warning between versions until it is feature complete and stabilized, including +the network protocol. + +To use the UI API, Luanti must be compiled with the `BUILD_UI` CMake option +turned on. The UI additionally requires SDL2 support, so `USE_SDL2` must also +be set. If Luanti is built without `BUILD_UI`, the `ui` namespace will still +exist in Lua, but the client will not have any C++ UI functionality and all UI +network packets will be silently ignored. + +> This documentation will sometimes refer to features that are not implemented +> in the current version of the UI API, such as scrollbars and edit fields. +> These are included in the documentation since they are particularly useful +> for explaining some of the features of the UI API that can't be explained as +> precisely otherwise. When this occurs, a block like this precedes the example +> to note the discrepancy between the documentation and current implementation. + +API design +---------- + +The UI API is exposed to Lua through the global `ui` namespace of functions and +classes. Most of these classes are opaque and effectively immutable, meaning +they have no user-visible properties or methods. Users must not access or +modify undocumented properties or inherit from any UI class. + +All tables passed into UI API functions are defensively copied by the API. +Modifying a table after passing it in to a function will not change the +constructed object in any way. + +### Example + +Here is a simple example of a working UI made with the UI API: + +> Since the UI API has no core theme bundled by default at this point (aside +> from the prelude theme), a few extra style properties are required to make +> anything visible. + +```lua +local function builder(context, player, state, param) + return ui.Window "gui" { + root = ui.Root { + size = {108, 76}, + padding = {4}, + + box_fill = "black#8C", + + ui.Label { + pos = {0, 0}, span = {1, 1/2}, + + label = "Hello, world!", + }, + + ui.Button "close" { + pos = {0, 1/2}, span = {1, 1/2}, + box_fill = "maroon", + + label = "Close", + + on_press = function(ev) + context:close() + minetest.chat_send_player(player, "The window has been closed") + end, + }, + }, + } +end + +core.register_on_joinplayer(function(player) + ui.Context(builder, player:get_player_name()):open() +end) +``` + +### ID strings + +Elements require unique IDs, which are represented as strings. ID strings may +only contain letters, numbers, dashes, underscores, and colons, and may not be +the empty string. + +All IDs starting with a dash or underscore are reserved for use by the engine, +and should not be used unless otherwise specified. IDs should not use a colon +except to include a `mod_name:` prefix. + +Element and group IDs are local to a single window, so the `mod_name:` prefix +used elsewhere in Luanti is generally unnecessary for them. However, if a +library mod creates themes, elements, or styles, then using a mod prefix for +the library's element and group IDs is highly encouraged to avoid ID conflicts. + +Derived element type names are placed in a global namespace, so mods should +always use a `mod_name:` prefix for mod-created derived elements. Only the +engine is allowed to make elements with unprefixed type names like `switch`. + +### Constructors + +The UI API makes heavy use of tables, currying, and Lua's syntactic sugar for +function calls to provide a convenient DSL-like syntax for creating UIs. For +instance, the curried function signature for elements is `ui.Elem(id)(props)` +or `ui.Elem(props)`, depending on whether the element is given an ID. To +illustrate how this is used, here are some examples for creating a label: + +```lua +-- For elements, the first curried argument is the element ID and the second is +-- a property table defining the element: +ui.Label("my_label")({ + label = "My label text", +}) + +-- Using Lua's syntactic sugar, we can drop the function call parentheses for +-- string and table literals, which is the preferred convention for the UI API: +ui.Label "my_label" { + label = "My label text", +} + +-- If the ID string or property table is a variable or expression, parentheses +-- are still required around one or both arguments: +local id = "my_label" +ui.Label(id) { + label = "My label text", +} + +-- If the ID is not necessary, it can be omitted altogether: +ui.Label { + label = "My label text", +} +``` + +The constructors for `ui.Window` and `ui.Style` use a similar curried function +signature. + +To further increase the convenience of element and style constructors, certain +properties may be "inlined" into the constructor table rather than specified as +an explicit table. For example, the list of children for an element can be +specified explicitly in the `children` property, or it can be put directly in +the constructor table if the `children` property is omitted: + +```lua +-- The `children` property explicitly specifies the list of children. +ui.Group { + children = { + ui.Label {label="Child 1"}, + ui.Label {label="Child 2"}, + }, +} + +-- The `children` property is omitted, so the list of children is taken from +-- constructor table instead. +ui.Group { + ui.Label {label="Child 1"}, + ui.Label {label="Child 2"}, +} +``` + +If the `children` property is explicitly specified, then elements placed +directly in the constructor table will be ignored. Other properties that may be +inlined follow similar rules. + +Unless otherwise documented, it should be assumed that all fields in +constructor tables are optional. + +Windows +------- + +_Windows_ represent discrete self-contained UIs formed by a tree of elements +and other parameters that affecting the entire window. Windows are represented +by the `ui.Window` class. + +### Root element + +The window contains a single element, the _root element_, in which the entire +element tree is contained. Because the root element has no parent, it is +positioned in the entire screen. The root element must be of type `ui.Root`. + +```lua +-- This creates a HUD-type window with the root element as its only element. +ui.Window "hud" { + root = ui.Root { + span = {1, 1}, + label = "Example HUD text", + }, +} +``` + +### Window types + +Windows require a _window type_, which determines whether they can receive user +input, what type of scaling to apply to the pixels, and the Z order of how they +are drawn in relation to other things on the screen. These are the following: + +* `filter`: Used for things that need to be drawn before everything else, such + as vignettes or filters covering the outside world. +* `mask`: Used for visual effects in between the camera and wieldhand, such as + masks or other such objects. +* `hud`: Used for normal HUD purposes. Hidden when the HUD is hidden. +* `chat`: Used to display GUI-like popup boxes or chat messages that can't be + interacted with. Hidden when the chat is hidden. +* `gui`: Used for GUIs that the user can interact with. + +If there are no formspecs open, then the topmost window (that is, the one that +was opened last) on the `gui` layer will receive user input from the keyboard, +mouse, and touchscreen. No other layer can receive user input. See the [Events +and input] section for more information on how `gui` windows handle user input. + +The `gui` and `chat` window types scale all dimensions by `real_gui_scaling` +pixels in size from `core.get_player_window_information()`, whereas every other +window type scales them by `real_hud_scaling`. + +The Z order for window types and other things displayed on the screen is: + +* Luanti world +* `filter` window types +* Wieldhand and crosshairs +* `mask` window types +* Player HUD API elements +* `hud` window types +* Nametags +* `chat` window types +* `gui` window types +* Formspecs + +If two or more windows of the same type are displayed at the same time, then +the windows that were opened more recently will be displayed on top of the +less recent ones. + +### Styling + +There are two properties in the window relevant to styling: `theme` and +`styles`. Both properties use `ui.Style` objects to select elements from the +entire element tree and apply styles to them. + +The `theme` property is meant for using an external theme provided by a game or +mod that gives default styling to different elements. If one is not specified +explicitly, the current default theme from `ui.get_default_theme()` will be +used instead. See [Theming] for more information. + +The `styles` property is intended for styling that is globally applied to a +single window. It takes higher precedence than the `theme` property, meaning +that properties set by any style within `styles` will override properties set +by `theme`. Any element styling that is specific to this particular window +should either reside in the `styles` property or in local element styles. + +```lua +ui.Window "gui" { + -- Set the theme for this window to a theme provided by the mod `my_mod`. + -- If this line is removed, the default theme set by the game will be used. + theme = my_mod.get_cool_theme(), + + -- This window also has its own particular styles, such as changing the + -- text color for some labels. These override properties set by the theme. + styles = { + ui.Style "label%warning" { + text_color = "yellow", + }, + ui.Style "label%error" { + text_color = "red", + }, + }, + + root = ui.Root { + size = {100, 40}, + + -- Aside from having any styling from the theme, this label will also + -- have red text due to the window style. Local styles could also be + -- added to the element itself to override any of these styles. + ui.Label { + groups = {"error"}, + label = "A problem has occurred", + }, + }, +} + +ui.Window "gui" { + theme = my_mod.get_cool_theme(), + + -- If the `styles` property is omitted, global styles can be inlined + -- directly into the window for convenience. + ui.Style "label%warning" { + text_color = "yellow", + }, + ui.Style "label%error" { + text_color = "red", + }, + + root = ui.Root {}, +} +``` + +Elements +-------- + +_Elements_ are the basic units of interface in the UI API and include such +things as sizers, buttons, and edit fields. Elements are represented by the +`ui.Elem` class and its subclasses. + +### Element IDs + +Each element in a window is required to have a unique _element ID_ that is +different from every other ID in that window. This ID uniquely identifies the +element for both network communication and styling. Elements that have user +interaction require an ID to be provided whereas static elements will +automatically generate an ID if none is provided. + +```lua +-- Buttons are an example of an element that require an ID, since they are +-- dynamic and have state on the client. +ui.Button "my_button" { + label = "My button", +} + +-- Labels are fully static, so they don't require an ID. +ui.Label { + label = "My label", +} +``` + +Each element's [Type info] section lists whether IDs must be provided. Elements +that are not given an ID will automatically generate one with `ui.new_id()`. + +```lua +-- Both of these elements will throw an error because buttons need unique IDs +-- that have not been automatically generated. +ui.Button { + label = "Missing ID", +} + +ui.Button(ui.new_id()) { + label = "Auto-generated ID", +} +``` + +If the ID for a dynamic element changes when the UI is updated, this will +result in the loss of the element's persistent state, as detailed below. + +### Styling + +Each element has a specific _type name_ that is used when referring to the +element in a `SelectorSpec`. The type name of each element is listed in the +element's [Type info] section. + +Elements can be styled according to their unique ID. Additionally, elements +also have a list of _group IDs_ that allow selectors to style multiple elements +at once. Group IDs are only used for styling. + +```lua +ui.Window "gui" { + style = ui.Style { + -- Style all `ui.Button` elements to have a maroon background by + -- styling the type name `button`. + ui.Style "button" { + box_fill = "maroon", + }, + + -- Style all elements with the group `yellow` to have yellow text. + ui.Style "%yellow" { + text_color = "yellow", + }, + }, + + root = ui.Root { + size = {212, 76}, + + scale = 1, + padding = {4}, + + box_fill = "black#8C", + + ui.Label { + pos = {0, 0}, span = {100, 32}, + + label = "No style", + }, + ui.Label { + pos = {104, 0}, span = {100, 32}, + + groups = {"yellow"}, + label = "Yellow text", + }, + ui.Button "a" { + pos = {0, 36}, span = {100, 32}, + + label = "Maroon background", + }, + ui.Button "b" { + pos = {104, 36}, span = {100, 32}, + + groups = {"yellow"}, + label = "Yellow text on maroon background", + }, + }, +} +``` + +Aside from the global styles found in the window, each element may have local +styles of its own that only apply to itself. Effectively, these styles are the +same as appending nested styles to the window's list of global styles with a +selector that only selects the element's ID; however, a local style is often +more convenient. Local styles have higher precedence than styles specified in +the window. + +```lua +-- This button uses local styles to set the button's background color to red +-- and also to make the text yellow when the box is hovered. +ui.Button "local" { + label = "Hovered yellow", + + styles = { + ui.Style { + box_fill = "red", + }, + ui.Style "$hovered" { + text_color = "yellow", + }, + }, +} + +-- This button inlines the styles into the element itself. Since the first +-- style has no selector, its properties may be inlined into the constructor +-- directly without being wrapped in a `ui.Style` with no selector. +ui.Button "inline" { + label = "Hovered yellow", + + box_fill = "red", + + ui.Style "$hovered" { + text_color = "yellow", + }, +} +``` + +### Child elements + +Each element has a list of _child elements_. Child elements are positioned +inside their parent element, and are thus subject to any special positioning +rules that a specific element has, such as in the case of scrolled elements. + +```lua +-- This group element has two buttons as children, positioned side-by-side. +ui.Group { + ui.Button "left" { + pos = {0, 0}, span = {1/2, 1}, + label = "Left", + }, + ui.Button "right" { + pos = {1/2, 0}, span = {1/2, 1}, + label = "Right", + }, +} + +-- Alternatively, we can use the `children` property to include an explicit +-- list of children rather than inlining them into the constructor. +ui.Group { + children = { + ui.Button "left" { + pos = {0, 0}, span = {1/2, 1}, + label = "Left", + }, + ui.Button "right" { + pos = {1/2, 0}, span = {1/2, 1}, + label = "Right", + }, + }, +} + +-- Both child elements and local styles may be inlined into the constructor +-- table at the same time and mixed freely. +ui.Group { + ui.Style { + box_fill = "red", + }, + + ui.Button "fill" { + label = "Button", + }, +} +``` + +The order in which elements are drawn is the parent element first, followed by +the first child and its descendants, then the second child, and so on, i.e. +drawing takes a pre-order search path. + +Each `ui.Elem` object can only be used once. After being set as the child +element of some other element or set as the root of a window, it cannot be +reused, either in the same window or in another window. + +### Persistent fields + +Certain elements and the window have properties that can be modified by the +user, such as checkboxes or edit fields. However, it must also be possible for +the server to modify them. Since there may be substantial latency between +client and server, it is undesirable for the server to update every +user-modifiable field every time the window is updated, as is the case with +formspecs, since that may overwrite the user's input. + +These fields that contain user input are called _persistent fields_. Normal +fields use a default value if the server omits the field. Persistent fields, on +the other hand, keep their previous value if the server omits the field. + +For example, suppose there is a window with a checkbox labelled `Check me` +which the user has checked. Then, the server updates the window, omitting both +the `label` and `selected` properties. Since `label` is a normal field, the +checkbox's label will become empty. However, `selected` is a persistent field, +so the checkbox will remain checked. If the server had explicitly set the +`selected` property to false, the checkbox would become unchecked. + +Changing persistent fields often has side effects. For instance, the UI API +doesn't support edit fields yet, but setting the `text` property on an edit +field would cause the caret to move to the end of the text. Similarly, the user +may have changed the state of a checkbox before a `ui.Context:update()` reached +the client, so always setting the `selected` property on that checkbox could +overwrite the user's input. As such, it is highly recommended to leave the +value for persistent fields at `nil` unless the server explicitly needs to +change the value. + +Note that omitted persistent fields are set to a default value when the element +is first created, such as when the window is opened or reopened. The `selected` +property, for instance, will be false when the window is first opened unless +the server gives it a value. + +### Derived elements + +Often, there are different types of elements that work the same way, but are +styled in vastly different ways that make them look like entirely different +controls to the user. For instance, `ui.Check` and `ui.Switch` are simply +toggle buttons like `ui.Toggle`, but have their own conventional appearances. + +Specialized appearances for these different controls can be made by using group +IDs, but it is often more convenient to have them act as different element +types entirely. Such elements are called _derived elements_, and can be created +using the `ui.derive_elem()` function. + +Derived elements are a purely server-side construct for styling, and act +exactly like their normal counterpart on the client. Moreover, all the fields +that can be provided to the constructor of the original type can also be +provided to the derived type. + +As an example, if a mod wanted to create a new special kind of toggle switch, +it could create a `MyToggle`, which acts exactly like a `ui.Toggle` except for +the lack of default styling: + +```lua +local MyToggle = ui.derive_elem(ui.Toggle, "my_mod:toggle") + +local function builder(context, player, state, param) + return ui.Window "gui" { + style = ui.Style { + -- We style our specific toggle with a basic blue color. + ui.Style "my_mod:toggle" { + box_fill = "navy", + }, + ui.Style "my_mod:toggle$selected" { + box_fill = "blue", + }, + + -- Standard toggles are styled entirely independently of elements + -- derived from them. + ui.Style "toggle" { + box_fill = "olive", + }, + ui.Style "toggle$selected" { + box_fill = "yellow", + }, + }, + + root = ui.Root { + size = {108, 76}, + padding = {4}, + + box_fill = "black#8C", + + MyToggle "my" { + pos = {0, 0}, span = {1, 1/2}, + label = "My toggle", + selected = true, + }, + ui.Toggle "ui" { + pos = {0, 1/2}, span = {1, 1/2}, + label = "Standard toggle", + }, + }, + } +end + +core.register_on_joinplayer(function(player) + ui.Context(builder, player:get_player_name()):open() +end) +``` + +All standard derived elements can be found in the [Derived elements] section of +their respective element's documentation. + +Boxes +----- + +Elements handle state and behavior, but the elements themselves are invisible +and can't be styled on their own. Instead, elements contain _boxes_, which +are rectangular regions inside each element that can be styled and denote the +visible bounds of each part of the element. + +Boxes can be styled with any of the style properties listed in `StyleSpec`, +such as box images or padding. Boxes also contain certain types of state +information relevant to styling, such as whether the mouse was pressed down +within the box's boundaries. + +### Box positioning + +Much like elements, boxes are arranged in a tree where each box can have one or +more child boxes. Every element has a `main` box which serves as the ancestor +of every other box in the element. The only exception to this rule is +`ui.Root`, which has a `backdrop` box as the parent of the `main` box. + +Each element type has a predefined set of boxes in a fixed hierarchy. For +instance, the box hierarchy for scrollbars looks like the following: + +> **Note**: Scrollbars are not implemented yet, so this documentation only +> serves as a representative example of box hierarchies. + +``` ++-----+--------------+-------------+-------------------------------+-----+ +| /__ | | = | | __\ | +| \ | | = | | / | ++-----+--------------+-------------+-------------------------------+-----+ +^^^^^^^. .^^^^^^^^^^^^^^^. .^^^^^^^ +decrease . thumb . increase +. . . . . . +. ^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ . +. . before after . . +. . . . +. ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ . +. track . +. . +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + main +``` + +Specifically, the `main` box has the scrollbar `track` box and the `decrease` +and `increase` boxes as children. The `track` box, in turn, has the `thumb` box +as a child to indicate the value of the scrollbar, plus `before` and `after` +boxes on either side of the thumb that can be used to give a separate style to +each half of the scrolled region. + +Boxes can have special positioning rules for their children. For scrollbars, +the `decrease`, `increase`, and `track` boxes have a bounding rectangle that +spans the entire `main` box. As such, they can be positioned freely, e.g. the +buttons could be moved to the left side of the scrollbar or even hidden +altogether. The bounding rectangle for the `thumb` box, however, depends on the +value of the scrollbar since the thumb moves as the value changes. Therefore, +`thumb` can only be positioned in a limited fashion within its bounding +rectangle. The situation is similar for `before` and `after`. + +Boxes are also in charge of where the content of the element is placed. For +simple elements like `ui.Button`, the children of the element and the label are +both positioned within the `main` box. More complex elements often handle +things differently. For instance, the `ui.Caption` element uses separate boxes +for these, `caption` for the label and `content` for the child elements. + +The list of boxes for each element is described in the element's [Type info] +section. The children and content of each box, plus any special behavior they +might have, is documented there. + +### Input and interaction + +Elements do not react to mouse or touch input directly. Instead, boxes within +the element handle these types of input, reacting to various situations such as +when the box is pressed or the cursor is hovering over the box. + +In the scrollbar example above, the `decrease` and `increase` boxes act like +buttons that can change the value of the scrollbar. The `thumb` is a box that +can be dragged by the pointer within the bounds of the track. These are known +as _dynamic_ boxes since they respond to mouse and touch input. On the other +hand, the `main` and `track` boxes are inert and ignore mouse and touch input +altogether. These are called _static_ boxes. + +Static boxes are invisible to mouse or touch events and let them pass through +to the box directly underneath them, and hence styling the `hovered` or +`pressed` states never has any effect. Dynamic boxes, on the other hand, +respond to events, so they always respond to styling the `pressed`, `hovered`, +and `focused` states. Moreover, dynamic boxes generally do not let mouse or +touch events pass through them. In particular, only the topmost dynamic box +under the cursor will have the `hovered` state set. + +To illustrate how events pass through different boxes, consider a button with +two children, a checkbox and a static label. If the mouse hovers over the +checkbox, which has a dynamic `main` box, the checkbox will have the `hovered` +state whereas the parent button will not. However, if the mouse hovers over the +label, which has a static `main` box, the event will pass through the label and +the parent button will have the `hovered` state instead. + +Events and input +---------------- + +_Events_ are the means through which user input is communicated from the client +to the server. They range from detecting when a checkbox was pressed to +notifying when focus changed from one element to another. + +Events come in two variants: _window events_ are events that are global to the +entire window, such as when the window has been closed or focus has changed. +_Element events_, on the other hand, fire when an event happens to a specific +element, such as a scrollbar being moved or a checkbox being toggled. + +When a server receives an event from the client, it calls the appropriate +_event handler_. Event handlers are callback functions that can be provided to +the window or to elements as properties. The signature of every event handler +is `function(ev)`, where `ev` is an `EventSpec` containing information about +the event, such as the new value of the scrollbar that was changed. See the +`EventSpec` documentation for the generic fields supported by all events. + +### Network latency + +Beware of the effects of network latency on event handlers, since an event +coming from the previous state of the element may surface after the element has +been updated by the server. For instance, the user might click a button, and +the server disables a checkbox in response. However, the user clicked the +checkbox before the client received the server's update, causing the server to +receive a checkbox event after it disabled the checkbox. + +The server could filter out these events, e.g. dropping all events for disabled +checkboxes, but this might lead to inconsistent state between the client and +server. In the previous example, the client would see the checkbox as checked, +but the server would still believe the checkbox was unchecked. This is a bad +situation, so the server will never drop events outright (except if the event +was for an element that was since removed). However, it will ensure that the +data contained in the event is valid, e.g. by clamping a scrollbar's value +to the current range of the scrollbar. + +Additionally, note that fake events may come from malicious clients, such as a +button press for a button that was never enabled. The server will filter out +events that are obviously incorrect, such as when they come from the wrong +player or from a window that is no longer open. Validation of the data +contained within each event is another line of defense. However, any stronger +validation, such as checking whether the user could have clicked that button at +all (given that it was always disabled) is impractical for the UI API to check +automatically, and hence the responsibility lies on the event handler if +absolute security is necessary. + +### Input dispatching + +Since windows usually contain many elements that may be nested or overlapping, +user input is dispatched to elements in a specific order. This also has +important impacts on if and when event handlers are called. + +First, the window contains a _focused element_, which is the element that has +keyboard focus. Elements that contain dynamic boxes, such as buttons and +scrollbars, can be focused. Static elements can never be focused. + +When a keyboard key is pressed or released, the window allows the focused +element to use the event first. If it ignores the input (such as if the element +has no dynamic boxes), the window allows its parent to use the input, and so +forth. If none of them used the keyboard input, or if there is no focused +element, then the window itself gets to use the input, possibly sending it to +the server. + +The window also contains a _hovered element_, which is the element that +primarily receives mouse and touch input. Just like the focused element, only +dynamic boxes may be the hovered element. + +When mouse or touch input is received by the window, it first allows the +focused element and its parents to peek at the event, such as to let buttons +become unpressed. Then, it sends the input to the hovered element. If it +ignores the input, the input passes to the element directly underneath it at +the mouse/touch position (which is not necessarily its parent element), and so +on. If none of them used the input, the window again gets to use the input, +possibly sending it to the server. + +### Window events + +The window has a few predefined ways of using user input besides passing the +input along to the server. + +The mouse is used for a number of features. When the mouse moves, the window +updates the currently hovered element. Similarly, when the left mouse button is +pressed, the window sets the focused element to the topmost focusable element +under the cursor. + +If certain keys are not handled by the focused element, then the window uses a +few of them for special purposes. The Escape key will cause the window to be +closed if `allow_close` is not set to false for the window, which will +naturally cause the `on_close` event to be fired. The Enter key will cause the +`on_submit` event to be sent to the server, which the server may use for +whatever purpose it desires, such as by closing the window and using the form's +data somewhere. + +The Tab key is used for changing which element has user focus via the keyboard. +Pressing Tab will cause the next focusable element in a preorder traversal of +the element tree to become focused, whereas pressing Shift + Tab will transfer +focus to the previous focusable element. + +Styles +------ + +_Styles_ are the interface through which the display and layout characteristics +of boxes are changed. They offer a wide variety of style properties that are +supported by every box. Styles can vary based on _states_, which indicate how +the user is interacting with a box. Styles are represented with the `ui.Style` +class. + +There are three components of a style: + +1. A `SelectorSpec` that decides which boxes and states to apply the style to. +2. A `StyleSpec` that contains the actual styling properties. +3. A list of nested styles with their own selectors and properties that are + cascaded with the ones in the parent style. + +One simple example of a style is the following: + +```lua +-- Select all buttons with the group ID `important` and give them yellow text +-- and a blue background. +ui.Style "button%important" { + text_color = "yellow", + box_fill = "blue", +} + +-- Alternatively, a `StyleSpec` with style properties can be included +-- explicitly with the `props` property rather than being inlined: +ui.Style "button%important" { + props = { + text_color = "yellow", + box_fill = "blue", + }, +} +``` + +### Selectors and properties + +See the respective sections on `SelectorSpec` and `StyleSpec` for advanced +details on selectors and the list of supported style properties. Additionally, +a comprehensive description of how style properties affect the layout and +display of boxes is also discussed in the [Layout and visuals] section. + +### States + +The list of states is as follows, from highest precedence to lowest: + +* `disabled`: The box is disabled, which means that user interaction with it + does nothing. +* `pressed`: The left mouse button was pressed down inside the boundaries of + the box, and has not yet been released. +* `hovered`: The mouse cursor is currently inside the boundaries of the box. +* `selected`: The box is currently selected in some way, e.g. a checkbox is + checked or a list item is selected. +* `focused`: The box currently has keyboard focus. + +The `pressed` and `hovered` states are properties of the box itself, and hence +only apply to dynamic boxes. Any dynamic box can be hovered and pressed, +although different boxes may have different requirements for what constitutes +being pressed. + +The `disabled`, `selected`, and `focused` states are often shared among +multiple boxes, and hence may apply to both static and dynamic boxes. For +example, when an individual scrollbar is disabled, every box within that +scrollbar has the `disabled` state. + +By default, it should be assumed that the `focused` state applies to every box +within the element if the element has focus. Moreover, if the element has a +`disabled` property, then the `disabled` state will also apply to every box +when that property is set. Boxes that have different behavior document how they +behave instead. + +### State cascading + +States fully cascade over each other. For instance, if there are styles that +give hovered buttons yellow text and pressed buttons blue backgrounds, then a +hovered and pressed button will have yellow text on a blue background. + +```lua +-- Creating two styles with separate states like so... +ui.Style "button$hovered" { + text_color = "yellow", +} +ui.Style "button$pressed" { + box_fill = "blue", +} + +-- ...automatically implies the following cascaded style as well: +ui.Style "button$hovered$pressed" { + text_color = "yellow", + box_fill = "blue", +} +``` + +However, if an element is currently in multiple states, then states with higher +precedence will override states with lower precedences. For instance, if one +style makes hovered buttons red and another makes pressed buttons blue, then a +button that is simultaneously hovered and pressed will be blue. + +```lua +-- One style makes hovered buttons red, and another makes pressed buttons blue. +ui.Style "button$hovered" { + box_fill = "red", +} +ui.Style "button$pressed" { + box_fill = "blue", +} + +-- Then, the implicit cascaded style will make buttons blue: +ui.Style "button$hovered$pressed" { + box_fill = "blue", +} +``` + +Lastly, state precedences can combine to form a higher precedence state. A +style for pressed and hovered buttons will override styles for only pressed or +only hovered buttons. Similarly, a style for disabled buttons will override the +style for pressed and hovered buttons since it has higher precedence than +either one. + +```lua +-- The first style makes hovered buttons red. +ui.Style "button$hovered" { + box_fill = "red", +} + +-- The second style makes buttons that are both hovered and pressed blue. +-- Therefore, hovered buttons will only be red if they are not pressed. +ui.Style "button$hovered$pressed" { + box_fill = "blue", +} + +-- If a button is disabled, this style makes it gray regardless of whether it +-- is hovered or pressed. +ui.Style "button$disabled" { + box_fill = "gray", +} +``` + +### Nested styles + +_Nested styles_ are a way of adding styles to a base style that apply extra +properties to a subset of the boxes. For instance, here is an example that +shows how nested styles work: + +```lua +-- Here is a style with nested styles contained within it. +ui.Style "button" { + box_fill = "yellow", + + ui.Style "$hovered" { + box_fill = "red", + }, + ui.Style "$pressed" { + box_fill = "blue", + }, +} + +-- That style is equivalent to having the following three styles: +ui.Style "button" { + box_fill = "gray", +} +ui.Style "button$hovered" { + box_fill = "red", +} +ui.Style "button$pressed" { + box_fill = "blue", +} + +-- The nested styles can also be explicitly included with the `nested` property +-- rather than being inlined into the style table: +ui.Style "button" { + box_fill = "yellow", + + nested = { + ui.Style "$hovered" { + box_fill = "red", + }, + ui.Style "$pressed" { + box_fill = "blue", + }, + }, +} +``` + +Nested styles are always evaluated top to bottom, so the parent style is +applied to the box first, and then each nested style is applied to the box in +order. This order-dependent styling is in direct contrast to CSS, which +calculates precedence based on weights associated with each part of the +selector. Order-dependence was deliberately chosen because it gives mods more +control over the style of their own windows without external themes causing +problems. The only weighting done by the UI API is for state selectors, as +described above, due to states being calculated on the client. + +There is no reason why a parent style containing nested styles must have a +selector or properties; nested styles can just be used for organizational +purposes by placing related styles in an empty parent style. Omitting the +selector causes it to be automatically set to the universal selector `*`. For +example, this style might be used as a theme for `ui.set_default_theme()`: + +```lua +local theme = ui.Style { + ui.Style "button, toggle, option" { + -- Style properties for all the different types of buttons. + }, + ui.Style "check, switch" { + -- Style properties for checkboxes and switches. + }, + ui.Style "radio" { + -- Style properties for radio buttons. + }, +} +``` + +### Style resets + +Sometimes, it is desireable to remove all existing style from a certain element +type. This can be done via _style resets_. The `reset` boolean, when set, +resets all style properties for the selected elements to their default values. + +For instance, if one style gives buttons a red background and nonzero padding, +then setting the `reset` property on a later style for buttons will reset that +to the defaults of a transparent background and no padding. Note that this will +also reset any properties set by the prelude theme, described in [Theming]. + +```lua +-- One style somewhere adds a bunch of properties to buttons. +ui.Style "button" { + box_fill = "red", + padding = {2, 2, 2, 2}, +} + +-- The button with the ID "special" needs unique styling, and hence uses the +-- `reset` property to get a clean slate for styling. +ui.Style "#special" { + reset = true, + text_color = "yellow", +} +``` + +Style resets cascade with style states as well. Resetting buttons with the +`hovered` state will also reset the properties for buttons that are both +hovered and pressed, for instance. Resetting `button` directly will reset every +state for the button as well. + +Theming +------- + +It is usually the case that different windows share the same basic element +styles. This concept is supported natively by the UI API through the use of +_themes_, which are large groups of styles that can be used in the `theme` +property of windows or set as the globally default theme. + +If a window doesn't have the `theme` property set, it will automatically use +the _default theme_, which is retrieved by `ui.get_default_theme()`. The engine +initially sets the default theme to the prelude theme, but games and mods can +change the default theme with the `ui.set_default_theme()` function. In the +future, the UI API will include a default theme called the "core theme", which +games can optionally use if they don't want to make their own theme or are in +early stages of development. + +### Prelude theme + +Nearly all themes should be based on the _prelude theme_, a theme that serves +no other purpose than being a base for other themes. The prelude theme sets +style properties that are essential to the functionality of the element, or are +at least a basic part of the element's intended design. It can be accessed with +the `ui.get_prelude_theme()` function. + +For instance, it is part of the basic functionality of `ui.Accordion` to hide +and show its contents, so the prelude hides the `content` box if the `selected` +state is not active. As another example, `ui.Image` almost always wants its +image to fill as much of the element as possible, so the prelude sets their +`icon_scale` property to zero. + +It is important to stress that the prelude theme does _not_ change the default +visuals of any elements. Box and icon images, tints, fills, paddings, margins, +and so on are never changed by the prelude theme, meaning all elements are +totally invisible by default and free to be themed. + +The prelude theme is designed to be highly stable, and should rarely change in +any substantial way. Moreover, each element documents what default styling the +prelude theme gives it in its [Theming] section, making it easy for new themes +to override prelude styling where necessary. + +When creating a new theme, the prelude theme should be included like so: + +```lua +local new_theme = ui.Style { + -- Include the prelude theme into this theme. + ui.get_prelude_theme(), + + -- Add any number of new styles to the theme, possibly overriding + -- properties set by the prelude theme. + ui.Style "root" { + box_fill = "navy#8C", + }, +} +``` + +To make a window with no theme, it is recommended to set the `theme` property +to `ui.get_prelude_theme()`. On rare occasions, it may be useful to make a +window that contains no styles, including those set by the prelude. In that +case, the `theme` property can be set to a blank `ui.Style {}`. + +Layout and visuals +------------------ + +The UI API has powerful support for positioning, sizing, and styling the +visuals and display of boxes. Rather than just supporting the strange +coordinate system of formspecs or the normalized coordinates of the player HUD +API, the UI API supports both pixel positioning and normalized coordinates. +Future versions will also support more advanced flex and grid layouts. +Moreover, every box supports a universal set of style properties for visuals, +rather than the limited and element-dependent styling of formspecs. + +See `StyleSpec` for a full list of supported style properties. + +### Box layout + +Boxes have a number of paddings and margins can be applied to add space inside +and around themselves. This leads to a number of conceptual rectangular areas +inside each box that serve various purposes. The following diagram illustrates +each of these rectangles: + +``` ++---------------------------------------------------------+ +| Layout rect | +| +-------------------------------------------------+ | +| |* Display rect * * * * * * * * * * * * * * * * *|<->| +| | * +-----------------------------------------+ * | Margin +| |* *| Middle rect |* *| | +| | * | +--------------------+ |<->| | +| |* *| +--------+ | Content rect | | Middle rect +| | * | |* Icon *| | | | . | | * border +| |* *| | * rect |<->| |---| | |<->|* *| | +| | * | |* * * * | Gutter | | | | Padding | +| |* *| +--------+ | | |* *| | +| | * | +--------------------+ | * | | +| |* *| |* *| | +| | * +-----------------------------------------+ * | | +| |* * * * * * * * * * * * * * * * * * * * * * * * *| | +| +-------------------------------------------------+ | +| | ++---------------------------------------------------------+ +``` + +After the box is positioned within its bounding rectangle (as described below), +the resulting positioned rectangle is called the _layout rectangle_. The +contents of the layout rectangle are inset by the `margin` property, making it +possible to add space between adjacent boxes. + +Inside the layout rectangle is the _display rectangle_, which as the name +implies is where the box is displayed. This is where the `box_*` style +properties draw their contents, e.g. `box_image` draws an image that is +stretched to the boundaries of the bounding rectangle. Additionally, the +display rectangle is where mouse and touch input are detected. + +If the `box_middle` property is set, then the `box_image` is drawn as a +nine-slice image where the image borders are scaled by the `box_scale` +property. The contents of the display rectangle are automatically inset by the +size of this border. This results in the _middle rectangle_, which in turn +insets its contents by the `padding` property to make the _padding rectangle_. + +Placed inside the padding rectangle is the _content rectangle_, which is where +the content of the box is placed. This might include text, child boxes, and/or +other special types of content contained in the element, or it might contain +nothing at all, such as in the case of scrollbar buttons and thumbs. + +Finally, if the `icon_image` property is set, then an _icon rectangle_ is +allocated to make space for the icon. The size of this rectangle is based on +the size of the icon image scaled by the `icon_scale` property. By default, the +icon rectangle is placed in the center of the content rectangle, but it can be +moved to any side of the content by using the `icon_place` property. The +`icon_overlap` property controls whether the content rectangle should overlap +the icon rectangle, which will cause any content to be displayed on top of the +icon. If they are not set to overlap, the `icon_gutter` property can be used to +control how much space will be placed between them. + +### Content layout + +TODO + +#### Place layout + +#### Flex and grid layout + +### Visual properties + +Some style properties that control the visual aspects of boxes have been +described above. However, there are other properties that can modify the +appearance of the box. + +The `display` property controls whether the box and its contents are visible +and/or clipped without affecting the layout of the box. The default is +`visible`, which makes the box and its contents visible, but they will be +clipped to the bounding rectangle. To prevent the box from being clipped at +all, the `overflow` value can be used instead. If the `hidden` value is used, +then the content will be displayed as normal, but the box and its icon will not +be drawn, even though they are still present and can be clicked as normal. +Lastly, the `clipped` value can be used to clip the box and its contents away +entirely. The box and contents still exist and take up space, but the mouse +will be unable to interact with them (although the keyboard still can). +Descendants of a `clipped` box can be shown by using the `overflow` value. + +The `box_*` and `icon_*` properties have some overlap in terms of style +properties. The `*_fill` properties fill the respective rectangle with a solid +color. As mentioned before, the `*_image` properties choose the image to draw +inside the rectangle. To draw only part of the image, a source rectangle can be +specified in normalized coordinates with `*_source`. The image can also be +colorized by using `*_tint`. Finally, for animated images, `*_frames` can be +used to specify the number of frames in the image, along with `*_frame_time` to +set the length of each frame in milliseconds. + +There are also some properties unique to both. As mentioned, the icon can use +`icon_scale` to set a scale factor for the icon. If this scale is set to 0, +then the icon rectangle fills up as much of the box as possible without +changing the aspect ratio of the image. + +For the display rectangle, the aforementioned `box_middle` property sets the +image to be a nine-slice image. Like `box_source`, it is specified in +normalized coordinates based on the image size. The `box_tile` property can be +used to have the image be tiled rather than streched, which tiles each slice +individually for a nine-slice image. Finally, the `box_scale` property scales +the size of each tile in a tiled image and the borders of a nine-slice image. + +Contexts +-------- + +In order to show a window, the UI API provides _contexts_, represented by the +`ui.Context` class. Contexts encapsulate the state of a single window and the +player it is shown to, and provide a means of opening, closing, rebuilding, and +updating the window. Unlike most classes in the UI API, `ui.Context` is not +immutable and contains public methods that can modify its state. + +### Builder functions + +In order to create a context, a _builder function_ must be provided. When +called, this function should return a window containing the desired element +tree. Every time the window is shown to a player or updated, the builder +function is called again. This allows the function to change the number of +elements or the properties they have. Since window and element objects are +immutable to the user, rebuilding everything using the builder function is the +only way to modify the UI. + +Each `ui.Window` object can only be used once. After being returned from a +builder function once, the same object cannot be returned from a builder +function again. The same applies to elements inside the window. + +Besides the builder function, the `ui.Context` constructor requires the name of +the player that the window is shown to. The builder function and player can be +extracted from a context using the `get_builder()` and `get_player()` methods +respectively. + +### Opening, updating, and closing windows + +After a context has been constructed, it can be shown to the player using the +`open()` method. If the state has changed and the UI needs to be rebuilt and +shown to the player, the `update()` method can be called. To close the window +programmatically, use the `close()` method. Open windows will be automatically +closed when the player leaves the game. + +When a window is closed, the `update()` and `close()` methods do nothing. +Similarly, when the window is open, the `open()` method does nothing. If +multiple windows need to be shown to a player, then multiple contexts must be +created since a context represents a single window. + +To query whether a context is open, use the `is_open()` method. To get a list of +all currently open contexts, use the `ui.get_open_contexts()` function. + +### Non-updatable properties + +Some properties cannot be updated via the `update()` method since that could +lead to race conditions where the server changed a property, but the client +sent an event that relied on the old property before it received the server's +changes. For most situations, this is not problematic, but it could cause weird +behavior for some changes, such as when changing the window type from `gui` to +another window type that doesn't use events. + +To change these non-updatable properties, use the `reopen()` method, which +is effectively the same as calling `context:close():open()`, but will do so in +a single atomic step (i.e. the player won't see the window disappear and +reappear). Additionally, `reopen()` will do nothing if the window is already +closed, just like `update()`. + +The `reopen()` method is not an exact replacement for `update()`. For one, it +will change the window's Z order by moving it in front of all other windows +with the same window type. Additionally, all persistent properties will be +changed by this operation, just like `open()`. + +### State and parameter tables + +Since UIs generally have some state associated with each open window, contexts +provide a means of holding state across calls to the builder function via a +_state table_. When a `ui.Context` is constructed, a state table can be +provided that holds state that will be passed to the builder function every +time it is called for this UI. This table can be modified by the builder +function or by event handlers in the UI. The UI API will never modify a state +table by itself. The state table can be obtained from a context via the +`get_state()` method or replaced entirely via `set_state()`. + +The following is a basic example that uses a state table: + +```lua +local function builder(context, player, state, param) + return ui.Window "gui" { + root = ui.Root { + size = {108, 40}, + padding = {4}, + + box_fill = "black#8C", + + ui.Button "counter" { + box_fill = "maroon", + label = "Clicks: " .. state.num_clicks, + + on_press = function(ev) + state.num_clicks = state.num_clicks + 1 + context:update() + end, + }, + }, + } +end + +core.register_on_joinplayer(function(player) + local state = {num_clicks = 0} + ui.Context(builder, player:get_player_name(), state):open() +end) +``` + +The state table is primarily for persistent data. However, it is often useful +to send temporary data that only applies to a single call of the builder +function, such as when setting persistent fields. This can be provided in the +form of a _parameter table_. Parameter tables can be provided to the methods +`open()`, `update()`, and `reopen()` and will be passed directly to the builder +function. After the builder function returns, the parameter table is discarded. + +The following is a basic example that sets a persistent field when first +opening the window by using parameter tables (note that it uses a slider, which +is not implemented yet): + +```lua +local function builder(context, player, state, param) + return ui.Window "gui" { + root = ui.Root { + size = {500, 40}, + padding = {4}, + + box_fill = "black#8C", + + ui.Slider "percent" { + label = state.scroll .. "%", + + min = 0, + max = 100, + + -- `param.set_scroll` is only set in `open()`, not in the + -- `update()` in `on_scroll`, so `value` is only set to + -- `state.scroll` when the window is first opened. + value = param.set_scroll and state.scroll, + + on_scroll = function(ev) + state.scroll = ev.value + context:update() + end, + }, + }, + } +end + +core.register_on_joinplayer(function(player) + local state = {scroll = 50} + local param = {set_scroll = true} + + ui.Context(builder, player:get_player_name(), state):open(param) +end) +``` + +Utility functions +----------------- + +* `ui.new_id()`: Returns a new unique ID string. + * **This function may not be used for elements that require an ID to be + provided, such as buttons or edit field elements!** These elements + require an ID that will stay constant across window updates! + * It is usually not necessary to use this function directly since the API + will automatically generate IDs when none is required. + * The format of this ID is not precisely specified, but it will have the + format of an engine reserved ID and will not conflict with any other IDs + generated during this session. +* `ui.is_id(str)`: Checks whether the argument is a string that follows the + format of an ID string. +* `ui.get_coord_size()`: Returns the size of a single coordinate in a + fixed-size formspec, i.e. a formspec with a size of `size[,,true]`. Can + be used when transitioning from formspecs to the UI API. +* `ui.derive_elem(elem, name)`: Creates and returns a new derived element type. + * `elem`: The element class to derive from, e.g. `ui.Toggle`. + * `name`: The type name for the derived element to use. The name should use + a `mod_name:` prefix. + * Returns the constructor for the new type, which can be used to create new + elements of the new derived type. +* `ui.get_prelude_theme()`: Returns the style defining the prelude theme. +* `ui.get_default_theme()`: Returns the style used as the default theme for + windows without an explicit theme. Defaults to the prelude theme for now. +* `ui.set_default_theme(theme)`: Sets the default theme to a new style. +* `ui.get_open_contexts()`: Returns a table containing the context objects for + all currently open windows. + +`ui.Context` +------------ + +Contexts encapsulate the state of a single window shown to a specific player, +as described in the [Contexts] section. + +### Constructor + +* `ui.Context(builder, player[, state])`: Creates a new context with a player + and an initial state table. The window is initially not open. + * `builder` (function): The builder function for the context. This function + takes four parameters, `function(context, player, state, param)`: + * `context` (`ui.Context`): The context itself. + * `player` (string): The name of the player that the window will be + shown to, equivalent to `context:get_player()`. + * `state` (table): The state table associated with this context, + equivalent to `context:get_state()`. + * `param` (table): The parameter table for this call to the builder + function. + * The function should return a freshly created `ui.Window` object. + * `player` (string): The player the context is associated with. + * `state` (table, optional): The initial state table for the window. If not + provided, defaults to an empty table. + +### Methods + +* `open([param])`: Builds a window and shows it to the player. Does nothing if + the window is already open. + * `param` (table, optional): The parameter table for this call to the + builder function. If not provided, defaults to an empty table. + * Returns `self` for method chaining. +* `update([param])`: Updates a window by rebuilding it and propagating the + changes to the player. Does nothing if the window is not open. + * `param` (table, optional): The parameter table for this call to the + builder function. If not provided, defaults to an empty table. + * Returns `self` for method chaining. +* `reopen([param])`: Reopens a window by rebuilding it, closing the player's + old window, and showing the new window to the player atomically. Does nothing + if the window is not open. + * `param` (table, optional): The parameter table for this call to the + builder function. If not provided, defaults to an empty table. + * Returns `self` for method chaining. +* `close()`: Closes a window that is currently shown to the player. Does + nothing if the window is not currently open. + * Returns `self` for method chaining. +* `is_open()`: Returns true if the window is currently open, otherwise false. +* `get_builder()`: Returns the builder function associated with the context. +* `get_player()`: Returns the player associated with the context. +* `get_state()`: Returns the state table associated with the context. +* `set_state(state)`: Sets a new state table for the context, replacing the + existing one. + * Returns `self` for method chaining. + +`ui.Window` +----------- + +Windows represent discrete self-contained UIs as described in the [Windows] +section. + +### Constructor + +* `ui.Window(type)(props)`: Creates a new window object. + * `type` (string): The window type for this window. This field cannot be + changed by `ui.Context:update()`. + * `props` (table): A table containing various fields for configuring the + window. See the [Fields] section for a list of all accepted fields. + +### Fields + +The following fields can be provided to the `ui.Window` constructor: + +* `root` (`ui.Root`, required): The root element for the element tree. +* `theme` (`ui.Style`): Specifies a style to use as the window's theme. + Defaults to the theme provided by `ui.get_default_theme()`. +* `styles` (table of `ui.Style`s): A table of global styles that apply across + the entire element tree. If this property is omitted, global styles may be + inlined into the constructor table. +* `allow_close` (boolean): Indicates whether the user is able to close the + window via the Escape key or similar. Defaults to true. This field cannot be + changed by `ui.Context:update()`. + +The following persistent fields can also be provided: + +* `focused` (ID string): If present, specifies the ID of an element to set as + the focused element. If set to the empty string, no element will have focus. + Newly created windows default to having no focused element. + +The following event handlers can also be provided: + +* `on_close`: Fired if the window was closed by the user. This event will never + be fired for windows with `allow_close` set to false. Additionally, the + player leaving the game and `ui.Context:close()` will never fire this event. +* `on_submit`: Fired if the user pressed the Enter key and that keypress was + not used by the focused element. +* `on_focus_change`: Fired when the user changed the currently focused element. + Additional `EventSpec` fields: + * `unfocused`: Contains the ID of the element that just lost focus, or the + empty string if no element was previously focused. + * `focused`: Contains the ID of the element that just gained focus, or the + empty string if the user unfocused the current element. + +`ui.Elem` +--------- + +Elements are the basic units of interface in the UI API, as described in the +[Elements] section. All elements inherit from the `ui.Elem` class. + +In general, plain `ui.Elem`s should not be used when there is a more +appropriate derived element, such as `ui.Image` for an element that has the +sole purpose of displaying an image. Moreover, plain `ui.Elem`s should never be +given default styling by any theme. + +### Type info + +* Type name: `elem` +* ID required: No +* Boxes: + * `main` (static): The main box of the element. Unless otherwise stated in + the documentation for other boxes, text content and child elements are + placed within this box. + +### Derived elements + +* `ui.Group` + * Type name: `group` + * A static element meant for holding generic grouping of elements for + layout purposes, similar to the HTML `
` element. +* `ui.Label` + * Type name: `label` + * A static element that is meant to be used for static textual labels in a + window. The regular `label` property should be used to display the text + for the label. +* `ui.Image` + * Type name: `image` + * A static element that only meant for displaying an image, either static + or animated. The `icon_image` style property should be used to display + the image, not the `box_image` property. + +### Theming + +For `ui.Image`, the prelude sets `icon_image` to zero to make the image fill as +much space as possible. There is no prelude theming for either `ui.Elem` or +`ui.Label`. + +### Constructor + +All elements have the same function signatures as `ui.Elem` for their +constructors and only differ in the properties accepted for `props`. + +* `ui.Elem(id)(props)`: Creates a new element object. + * `id` (ID string): The unique ID for this element. It is only required if + stated as such in the element's [Type info] section. + * `props` (table): A table containing various fields for configuring the + element. See the [Fields] section for a list of all accepted fields. +* `ui.Elem(props)`: Same as the above constructor, but generates an ID with + `ui.new_id()` automatically. + +### Fields + +The following fields can be provided to the `ui.Elem` constructor: + +* `label` (string): The text label to display for the element. +* `groups` (table of ID strings): The list of group IDs for this element. +* `props` (`StyleSpec`): A table of style properties that only apply to this + element's `main` box without any state selectors applied. If this property is + omitted, these style properties may be inlined into the constructor table. +* `styles` (table of `ui.Style`s): A table of local styles that only apply to + this element. If this property is omitted, local styles may be inlined into + the constructor table. +* `children` (table of `ui.Elem`s): The list of elements that are children of + this element. If this property is omitted, children may be inlined into the + constructor table. + +`ui.Root` +--------- + +The root element is a special type of element that is used for the sole purpose +of being the root of the element tree. Root elements may not be used anywhere +else in the element tree. + +Aside from its `main` box, the root element also has a `backdrop` box as the +the parent of its `main` box. The backdrop takes up the entire screen behind +the window and is intended to be used for dimming or hiding things behind a +window with a translucent or opaque background. + +The root element is fully static, but the `backdrop` box will have the +`focused` state set if the window is a GUI window that has user focus. In +general, the `backdrop` box should be invisible unless it is focused. This +prevents backdrops from different windows from stacking on top of each other, +which generally leads to an undesirable appearance. + +The `backdrop` box does not count as part of the element for mouse clicks, so +clicking on the backdrop counts as clicking outside the window. + +### Type info + +* Type name: `root` +* ID required: No +* Boxes: + * `backdrop` (static): The backdrop box, which has the entire screen as its + layout rectangle. It is the parent of the `main` box. + * `main` (static): The main box. See `ui.Elem` for more details. + +### Theming + +The prelude centers the `main` box on the screen with `pos` and `anchor`, but +gives it no size, so users must explicitly choose a size with `span` and/or +`size`. Additionally, the prelude sets `display` to `hidden` for the backdrop +unless it has the `focused` state set. The backdrop also sets `clip` to `both` +to ensure that the backdrop never expands past the screen size even if its +content does. + +### Fields + +The fields that can be provided to the `ui.Root` constructor are the same as +those in `ui.Elem`. + +`ui.Button` +----------- + +The button is a very simple interactive element that can do nothing except be +clicked. When the button is clicked with the mouse or pressed with the +spacebar, then the `on_press` event is fired unless the button is disabled. + +### Type info + +* Type name: `button` +* ID required: Yes +* Boxes: + * `main` (dynamic): The main box, which constitutes the pressable portion + of the button. Also see `ui.Elem` for more details. + +### Theming + +There is no prelude theming for `ui.Button`. + +### Fields + +In additional to all fields in `ui.Elem`, the following fields can be provided +to the `ui.Button` constructor: + +* `disabled` (boolean): Indicates whether the button is disabled, meaning the + user cannot interact with it. Default false. + +The following event handlers can also be provided: + +* `on_press`: Fired if the button was just pressed. + +`ui.Toggle` +----------- + +The toggle button is a type of button that has two states: selected and +deselected. In its simplest form, the state of the toggle button flips between +selected and deselected whenever it is pressed. The state of the toggle button +can be controlled programmatically by the persistent `selected` property. + +Toggle buttons have two events, `on_press` and `on_change`, which fire +simultaneously. Although `on_change` is a strict superset of the functionality +of `on_press`, both are provided for parity with `ui.Option`. + +### Type info + +* Type name: `toggle` +* ID required: Yes +* Boxes: + * `main` (dynamic): The main box, which constitutes the pressable portion + of the toggle button. The `selected` state is active when the toggle + button is selected. Also see `ui.Elem` for more details. + +### Derived elements + +* `ui.Check` + * Type name: `check` + * A variant of the toggle button that is meant to be styled like a + traditional checkbox rather than a pushable button. +* `ui.Switch` + * Type name: `switch` + * A variant of the toggle button that is meant to be styled like a switch + control; that is, a horizontal switch where left corresponds to + deselected and right corresponds to selected. + +### Theming + +The prelude horizontally aligns the icon image for `ui.Check` and `ui.Switch` +to the left. There is no prelude theming for `ui.Toggle`. + +### Fields + +In additional to all fields in `ui.Elem`, the following fields can be provided +to the `ui.Toggle` constructor: + +* `disabled` (boolean): Indicates whether the toggle button is disabled, + meaning the user cannot interact with it. Default false. + +The following persistent fields can also be provided: + +* `selected` (boolean): If present, changes the state of the toggle button to a + new value. Newly created toggle buttons default to false. + +The following event handlers can also be provided: + +* `on_press`: Fired if the toggle button was just pressed. +* `on_change`: Fired if the value of the toggle button changed. Additional + `EventSpec` fields: + * `selected` (boolean): The state of the toggle button. + +`ui.Option` +----------- + +The option button is similar to a toggle button in the sense that it can be +either selected or deselected. However, option buttons are grouped into +_families_ such that exactly one option button in each family can be selected +at a time by the user. + +The family of an option button is controlled by the `family` property, which is +set to a ID string that is shared by each option button in the family. If no +family is provided, the option button acts as if it were alone in a family with +one member. + +When the user presses a non-disabled option button, that button is selected and +all the other buttons in the family (including disabled ones) are deselected. +Although the user can only select one option button, zero or more option +buttons may be set programmatically via the persistent `selected` property. + +Option buttons have two events: `on_press` and `on_change`. The `on_press` +event occurs whenever a user presses an option button, even if that button was +already selected. The `on_change` event occurs whenever the value of an option +button changes, whether that be the button the user selected or the other +buttons in the family that were deselected. The `on_change` event will fire for +the selected button first, followed by the deselected buttons. + +### Type info + +* Type name: `option` +* ID required: Yes +* Boxes: + * `main` (dynamic): The main box, which constitutes the pressable portion + of the option button. The `selected` state is active when the option + button is selected. Also see `ui.Elem` for more details. + +### Derived elements + +* `ui.Radio` + * Type name: `radio` + * A variant of the option button that is meant to be styled like a + traditional radio button rather than a pushable button. + +### Theming + +The prelude horizontally aligns the icon image for `ui.Radio` to the left. +There is no prelude theming for `ui.Option`. + +### Fields + +In additional to all fields in `ui.Elem`, the following fields can be provided +to the `ui.Option` constructor: + +* `disabled` (boolean): Indicates whether the option button is disabled, + meaning the user cannot interact with it. Default false. +* `family` (ID string): Sets the family of the option button. If none is + provided, the option button works independently of any others. Default none. + +The following persistent fields can also be provided: + +* `selected` (boolean): If present, changes the state of the option button to a + new value. Newly created option buttons default to false. + +The following event handlers can also be provided: + +* `on_press`: Fired if the option button was just pressed. +* `on_change`: Fired if the value of the option button changed. Additional + `EventSpec` fields: + * `selected` (boolean): The state of the option button. + +`ui.Style` +---------- + +Styles are the interface through which the display and layout characteristics +of element boxes are changed, as described in [Styles]. + +### Constructor + +* `ui.Style(sel)(props)`: Creates a new style object. + * `sel` (`SelectorSpec`): The primary selector that this style applies to. + * `props` (table): A table containing various fields for configuring the + style. See the [Fields] section for a list of all accepted fields. +* `ui.Style(props)`: Same as the above constructor, but the selector defaults + to `*` instead. + +### Fields + +The following fields can be provided to the `ui.Style` constructor: + +* `props` (`StyleSpec`): The table of properties applied by this style. If + this property is omitted, style properties may be inlined into the + constructor table. +* `nested` (table of `ui.Style`s): A list of `ui.Style`s that should be used as + the cascading nested styles of this style. If this property is omitted, + nested styles may be inlined into the constructor table. +* `reset` (boolean): If true, resets all style properties for the selected + elements to their default values before applying the new properties. + +`SelectorSpec` +-------------- + +A _selector_ is a string similar to a CSS selector that matches elements by +various attributes, such as their ID, group, and state, what children they +have, etc. Many of the same concepts apply from CSS, and there are a few +similarities in the syntax, but there is no compatibility between the two. + +### Usage and syntax + +A selector is composed of one or more _terms_. For instance, the `button` term +selects all button elements, whereas the `$hovered` term selects all boxes in +the hovered state. Terms can be combined with each other by concatenation to +form a longer and more specific selector. For instance, the selector +`button$hovered` selects elements that are both buttons and are hovered. + +Using a comma between terms forms the union of both terms. So, `button, +$hovered` selects terms that are either buttons or are hovered. These can be +combined freely, e.g. `button$pressed, scrollbar$hovered` selects elements that +are pressed buttons or hovered scrollbars. + +The order of operations for these operations can be controlled by parentheses, +so `(button, check)($pressed, $hovered)` is the same as the much longer +selector `button$pressed, button$hovered, check$pressed, check$hovered`. + +Note that it is not invalid for a selector to have contradictory terms. The +selector `#abc#xyz` is valid, but will never select anything since an element +can only have a single ID. + +Whitespace between terms is ignored, so both `button$hovered` and `button +$hovered` are valid and equivalent, although it is customary to only put +whitespace after commas. The order that selectors are written in is also +irrelevant, so `button$hovered` and `$hovered button` are equivalent, although +the former order is preferred by convention. + +### Basic terms + +The full list of basic terms is as follows, listed in the conventional order +that they should be written in: + +* `/window/` matches any element inside a window with window type `window`. +* `*` matches every element. This is necessary since empty selectors and terms + are invalid. It is redundant when combined with other terms. +* `type` matches any element with the type name `type`. Inheritance is ignored, + so `elem` matches `ui.Elem` but not `ui.Label` or `ui.Button`. +* `#id` matches any element with the ID `id`. +* `%group` matches any element that contains the group `group`. +* `@*` matches any box within any element, not just the `main` box. +* `@box` matches any box with the name `box` within any element. +* `$state` matches any box currently in the state `state` within any element. + +### Box selectors + +The `@box` selector selects a specific box to style. For instance, a selector +of `scrollbar%overflow@thumb` can be used to style the thumb of any scrollbar +with the group `overflow`. On the other hand, `button@thumb` will not match +anything since buttons don't have a `thumb` box. Similarly, `@main@thumb` will +not match anything since a box cannot be two separate boxes at once. + +By default, a selector that contains no box selector will only match the `main` +box. Alternatively, the special `@*` box selector can be used to select every +box in the element. For instance, `accordion@*` would be equivalent to +`accordion(@main, @caption, @content)`. This selector is especially useful when +performing style resets for an entire element and all the boxes within it. + +### Predicates + +Terms can be combined with more complicated operators called _predicates_. The +simplest predicate is the `!` predicate, which inverts a selector. So `!%tall` +selects all elements without the `tall` group. Note that `!` only applies to +the term directly after it, so `!button%tall` means `(!button)%tall`. To invert +both terms, use `!(button%tall)`. + +Predicates can only work with certain types of selectors, called _predicate +selectors_. Predicate selectors cannot use box or state selectors, since the +server has no knowledge of which boxes are in which states at any given time. +For example, the selector `button!$pressed` is invalid. On the other hand, +the selector that `ui.Style` uses is called a _primary selector_, which is a +selector that is allowed to use boxes and states outside of predicates. + +All predicates other than `!` start with the `?` symbol. _Simple predicates_ +are one such predicate type, which match an element based on some intrinsic +property of that element. The `?first_child` predicate, for instance, checks +if an element is the first child of its parent. + +There are also _function predicates_ which take extra parameters, usually a +selector, to select the element. For instance, the `?<()` predicate tries to +match a selector `sel` against a parent element. So, `?<(button, check)` +matches all elements that have a button or a checkbox as their parent. Not all +function predicates take selectors as parameters, such as `?nth_child()`, which +takes a positive integer instead. + +### List of predicates + +The complete list of predicates aside from `!` is as follows: + +* `?no_children` matches all elements with no children. +* `?first_child` matches all elements that are the first child of their parent + element. +* `?last_child` matches all elements that are the last child of their parent + element. +* `?only_child` matches all elements that are the only child of their parent + element. +* `?nth_child(index)` matches all elements that are at the `index`th child + from the start of their parent. +* `?nth_last_child(index)` matches all elements that are the `index`th + child from the end of their parent. +* `?first_match(sel)` matches all elements that are the first child of their + parent to be matched by the selector `sel`. +* `?last_match(sel)` matches all elements that are the last child of their + parent to be matched by the selector `sel`. +* `?only_match(sel)` matches all elements that are the only child of their + parent to be matched by the selector `sel`. +* `?nth_match(sel; index)` matches all elements that are the `index`th child + from the start of their parent to be matched by the selector `sel`. +* `?nth_last_match(sel; index)` matches all elements that are the `index`th + child from the end of their parent to be matched by the selector `sel`. +* `?<(sel)` matches all elements whose parent is matched by the selector `sel`. +* `?>(sel)` matches all elements that have a child that is matched by the + selector `sel`. +* `?<<(sel)` matches all elements that have an ancestor that is matched by the + selector `sel`. +* `?>>(sel)` matches all elements that have a descendant that is matched by the + selector `sel`. +* `?<>(sel)` matches all elements that have a sibling that is matched by the + selector `sel`. +* `?family(name)` matches all elements that have a family of `name`. If `name` + is `*`, then it matches elements that have a family, regardless of which one. + +Note that the root element is considered to be the only "child" of the window, +so it is matched by e.g. `?first_child` and `?only_match(root)`. + +Unlike CSS, there are no child or descendant combinators since the more +powerful `?<()` and `?<<()` predicates can be used instead. Note that +predicates like `?>>()` should be used with care since they may have to check a +large number of elements in order to see if there is a match, which may cause +loss of performance for particularly large UIs. + +`StyleSpec` +------------ + +A `StyleSpec` is a plain table of properties for use in `ui.Style` or as +inlined properties in `ui.Elem`. + +### Field formats + +`StyleSpec` has specific field formats for positions and rectangles: + +* 2D vector: A table `{x, y}` representing a position, size, or offset. The + shorthand `{num}` can be used instead of `{num, num}`. +* Rectangle: A table `{left, top, right, bottom}` representing a rectangle or + set of borders. The shorthand `{x, y}` can be used instead of `{x, y, x, y}`, + and `{num}` can be used instead of `{num, num, num, num}`. + +### Fields + +All properties are optional. Invalid properties are ignored. + +#### Layout fields + +* `layout` (string): Chooses what layout scheme this box will use when laying + its children out. Currently, the only valid option is `place`. +* `clip` (string): Normally, a box expands its minimum size if there's not + enough space for the content, but this property can specify that the content + be clipped in the horizontal and/or vertical directions instead. One of + `none`, `x`, `y`, or `both`. Default `none`. +* `scale` (number): The scale factor by which positions and sizes will be + multiplied by in `place` layouts. Default 0. + * A scale of zero will scale coordinates by the width and height of the + parent box, effectively creating normalized coordinates. + +#### Sizing fields + +* `size` (2D vector): The minimum size of the box in pixels. May not be + negative. Default `{0, 0}`. +* `span` (2D vector): The size that the layout scheme will allocate for the + box, scaled by `scale`. Default `{1, 1}`. +* `pos` (2D vector): The position that the layout scheme will place the box at, + scaled by `scale`. Default `{0, 0}`. +* `anchor` (2D vector): The point which should be considered the origin for + placing the box. Default `{0, 0}`. + * Specified in normalized coordinates relative to the size of the box + itself, e.g. `{1/2, 1/2}` means to position the box from its center. +* `margin` (rectangle): Margin in pixels of blank space between the layout + rectangle and the display rectangle. It is valid for margins to be negative. + Default `{0, 0, 0, 0}`. +* `padding` (rectangle): Padding in pixels of blank space between the middle + rectangle and the padding rectangle. It is valid for padding to be negative. + Default `{0, 0, 0, 0}`. + +#### Visual fields + +* `display` (string): Specifies how to display this box and its contents. One + of `visible`, `overflow`, `hidden`, or `clipped`. Default `visible`. + +#### Box fields + +* `box_image` (texture): Image to draw in the display rectangle of the box, or + `""` for no image. The image is stretched to fit the rectangle. Default `""`. +* `box_fill` (`ColorSpec`): Color to fill the display rectangle of the box + with, drawn behind the box image. Default transparent. +* `box_tint` (`ColorSpec`): Color to multiply the box image by. Default white. +* `box_source` (rectangle): Allows a sub-rectangle of the box image to be drawn + from instead of the whole image. Default `{0, 0, 1, 1}`. + * Uses normalized coordinates relative to the size of the texture. E.g. + `{1/2, 1/2, 1, 1}` draws the lower right quadrant of the texture. This + makes source rectangles friendly to texture packs with varying base + texture sizes. + * The top left coordinates may be greater than the bottom right + coordinates, which flips the image. E.g. `{1, 0, 0, 1}` flips the image + horizontally. + * Coordinates may extend past the image boundaries, including being + negative, which repeats the texture. E.g. `{-1, 0, 2, 1}` displays three + copies of the texture side by side. +* `box_frames` (integer): If the box image should be animated, the source + rectangle is vertically split into this many frames that will be animated, + starting with the top. Must be positive. If set to one, the image will be + static. Default 1. +* `box_frame_time` (integer): Time in milliseconds to display each frame in an + animated box image for. Must be positive. Default 1000. +* `box_middle` (rectangle): If the box image is to be a nine-slice image (see + ), then this defines the size + of each border of the nine-slice image. Default `{0, 0, 0, 0}`. + * Uses normalized coordinates relative to the size of the texture. E.g. + `{2/16, 1/16, 2/16, 1/16}` will make the horizontal borders 2 pixels and + the vertical borders 1 pixel on a 16 by 16 image. May not be negative. + * In conjunction with `box_scale`, this also defines the space between the + display rectangle and the middle rectangle. +* `box_tile` (string): Specifies whether the image should be tiled rather than + stretched in the horizontal or vertical direction. One of `none`, `x`, `y`, + or `both`. Default `none`. + * If used in conjunction with `box_middle`, then each slice of the image + will be tiled individually. +* `box_scale` (number): Defines the scaling factor by which each tile in a + tiled image and the borders of a nine-slice image should be scaled by. May + not be negative. Default 1. + +#### Icon fields + +* `icon_image` (texture): Image to draw in the icon rectangle of the box, or + `""` for no image. The image always maintains its aspect ratio. Default `""`. +* `icon_fill` (`ColorSpec`): Color to fill the icon rectangle of the box with, + drawn behind the icon image. Default transparent. +* `icon_tint` (`ColorSpec`): See `box_tint`, but for `icon_image`. +* `icon_source` (rectangle): See `box_source`, but for `icon_image`. +* `icon_frames` (integer): See `box_frames`, but for `icon_image`. +* `icon_frame_time` (integer): See `box_frame_time`, but for `icon_image`. +* `icon_scale` (number): Scales the icon up by a specific factor. Default 1. + * For instance, a factor of two will make the icon twice as large as the + size of the texture. + * A scale of 0 will make the icon take up as much room as possible without + being larger than the box itself. The scale may not be negative. +* `icon_place` (string): Determines which side of the padding rectangle the + icon rectangle should be placed on. One of `center`, `left`, `top`, `right`, + or `bottom`. Default `center`. +* `icon_overlap` (boolean): Determines whether the content rectangle should + overlap the icon rectangle. If `icon_place` is `center`, then they will + always overlap and this property has no effect. Default false. +* `icon_gutter` (number): Space in pixels between the content and icon + rectangles if they are not set to overlap. It is valid for the gutter to be + negative. This property has no effect if no icon image is set. Default 0. + +`EventSpec` +----------- + +An `EventSpec` is a plain table that is passed to event handler functions to +give detailed information about what changed during the event and where the +event was targeted. + +An `EventSpec` always contains the following fields: + +* `context` (`ui.Context`): The context that the event originates from. +* `player` (string): The name of the player that the window is shown to, + equivalent to `context:get_player()`. +* `state` (table): The state table associated with the context, equivalent to + `context:get_state()`. + +Additionally, `EventSpec`s that are sent to elements have an additional field: + +* `target` (ID string): The ID of the element that the event originates from. + +`EventSpec`s may have other fields, depending on the specific event. See each +event handler's documentation for more detail. diff --git a/doc/lua_api.md b/doc/lua_api.md index 438769085..49e69aea4 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -4237,6 +4237,8 @@ Helper functions * since 5.12 * `table` can also be non-table value, which will be returned as-is * preserves metatables as they are + * Returns a deep copy of `table`, i.e. a copy of the table and all its + 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 @@ -4248,6 +4250,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. @@ -5971,6 +5976,9 @@ Utilities form. If the ColorSpec is invalid, returns `nil`. You can use this to parse ColorStrings. * `colorspec`: The ColorSpec to convert +* `core.colorspec_to_int(colorspec)`: Converts a ColorSpec to integer form. + If the ColorSpec is invalid, returns `nil`. + * `colorspec`: The ColorSpec to convert * `core.time_to_day_night_ratio(time_of_day)`: Returns a "day-night ratio" value (as accepted by `ObjectRef:override_day_night_ratio`) that is equivalent to the given "time of day" value (as returned by `core.get_timeofday`). @@ -5979,8 +5987,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. @@ -5990,6 +5998,63 @@ Utilities * `core.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). +* `core.class([super])`: Creates a new metatable-based class. + * `super` (optional): The superclass of the newly created class, or nil if + the class should not have a superclass. + * The class table is given a metatable with an `__index` field that points + to `super` and a `__call` metamethod that constructs a new object. + * By default, the only field in the class table is an `__index` field that + points to itself. When an instance of the class is created, the class + table is set as the metatable for the object. + * When a new object is constructed via `__call`, the `new()` method will be + called if it exists. If `new()` returns a nonzero number of values, then + those values will be returned from `__call`. Otherwise, if `new()` + returned no values, the object iself will be returned. + * Extra Lua metamethods like `__add` may be added to the class table, but + note that these fields will not be inherited by subclasses since Lua + doesn't consult `__index` when searching for metamethods. + * Example: The following code, demonstrating a simple example of classes + and inheritance, will print `Rectangle[area=6, filled=true]`: + ```lua + local Shape = core.class() + Shape.name = "Shape" + + function Shape:new(filled) + self.filled = filled + end + + function Shape:describe() + return string.format("%s[area=%d, filled=%s]", + self.name, self:get_area(), self.filled) + end + + local Rectangle = core.class(Shape) + Rectangle.name = "Rectangle" + + 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()) + + assert(core.is_instance(shape, Shape)) + assert(core.is_subclass(Rectangle, Shape)) + assert(core.super(Rectangle) == Shape) + ``` +* `core.super(class)`: Returns the superclass of `class`, or nil if the table + is not a class or has no superclass. +* `core.is_subclass(class, super)`: Returns true if `class` is a subclass of + `super`. +* `core.is_instance(obj, class)`: Returns true if `obj` is an instance of + `class` or any of its subclasses. Logging ------- @@ -7672,6 +7737,49 @@ Misc. * Example: `deserialize('print("foo")')`, returns `nil` (function call fails), returns `error:[string "print("foo")"]:1: attempt to call global 'print' (a nil value)` +* `core.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. +* `core.decode_network(format, data, ...)`: Decodes numbers and strings from + binary format made by `core.encode_network()` according to a format string. + * The format string follows the same rules as `core.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. * `core.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 28cc2c1eb..132cc02ba 100644 --- a/games/devtest/mods/unittests/misc.lua +++ b/games/devtest/mods/unittests/misc.lua @@ -341,3 +341,202 @@ local function test_ipc_poll(cb) print("delta: " .. (core.get_us_time() - t0) .. "us") end unittests.register("test_ipc_poll", test_ipc_poll) + +unittests.register("test_encode_network", function() + -- 8-bit integers + assert(core.encode_network("bbbbbbb", 0, 1, -1, -128, 127, 255, 256) == + "\x00\x01\xFF\x80\x7F\xFF\x00") + assert(core.encode_network("BBBBBBB", 0, 1, -1, -128, 127, 255, 256) == + "\x00\x01\xFF\x80\x7F\xFF\x00") + + -- 16-bit integers + assert(core.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(core.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(core.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(core.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(core.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(core.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(core.encode_network("ssss", + "", "hello", + max_16, max_16.."too long") == + "\x00\x00".. "\x00\x05hello".. + "\xFF\xFF"..max_16.."\xFF\xFF"..max_16) + assert(core.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(core.encode_network("zzzz", + "", "hello", "hello\0embedded", max_16.."longer") == + "\0".."hello\0".."hello\0".. max_16.."longer\0") + assert(core.encode_network("ZZZZ", + "", "hello", "hello\0embedded", max_16.."longer") == + "".."hello".."hello\0embedded"..max_16.."longer") + + -- Spaces + assert(core.encode_network("B I", 255, 2^31) == "\xFF\x80\x00\x00\x00") + assert(core.encode_network(" B Zz ", 15, "abc", "xyz") == "\x0Fabcxyz\0") + + -- Empty format strings + assert(core.encode_network("") == "") + assert(core.encode_network(" ", 5, "extra args") == "") +end) + +unittests.register("test_decode_network", function() + local d + + -- 8-bit integers + d = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = core.encode_network("fff", + 0.0, 123.456, -987.654) + assert(#enc == 3 * 4) + + d = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.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 = {core.decode_network("B I", "\xFF\x80\x00\x00\x00")} + assert(#d == 2) + assert(d[1] == 255 and d[2] == 2^31) + + d = {core.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 = {core.decode_network("", "some random data")} + assert(#d == 0) + d = {core.decode_network(" ", "some random data", 3, 5)} + assert(#d == 0) +end) diff --git a/irr/include/IEventReceiver.h b/irr/include/IEventReceiver.h index 332b23158..c886f72c1 100644 --- a/irr/include/IEventReceiver.h +++ b/irr/include/IEventReceiver.h @@ -8,6 +8,8 @@ #include "Keycodes.h" #include "irrString.h" +union SDL_Event; + namespace irr { //! Enumeration for all event types there are. @@ -82,6 +84,11 @@ enum EEVENT_TYPE //! Application state events like a resume, pause etc. EET_APPLICATION_EVENT, + //! Any other raw SDL event that doesn't fall into the above categories. In + // this case, only SEvent::SdlEvent contains valid information. Note that + // SEvent::SdlEvent is non-nullptr for most other event types as well. + EET_OTHER_SDL_EVENT, + //! This enum is never used, it only forces the compiler to //! compile these enumeration values to 32 bit. EGUIET_FORCE_32_BIT = 0x7fffffff @@ -526,6 +533,7 @@ struct SEvent }; EEVENT_TYPE EventType; + union SDL_Event *SdlEvent = nullptr; union { struct SGUIEvent GUIEvent; diff --git a/irr/src/CIrrDeviceSDL.cpp b/irr/src/CIrrDeviceSDL.cpp index bc7627f73..ea35d7b3a 100644 --- a/irr/src/CIrrDeviceSDL.cpp +++ b/irr/src/CIrrDeviceSDL.cpp @@ -714,6 +714,10 @@ bool CIrrDeviceSDL::run() // os::Printer::log("event: ", core::stringc((int)SDL_event.type).c_str(), ELL_INFORMATION); // just for debugging irrevent = {}; + // Initially, we assume that there is no other applicable event type. + irrevent.EventType = irr::EET_OTHER_SDL_EVENT; + irrevent.SdlEvent = &SDL_event; + switch (SDL_event.type) { case SDL_MOUSEMOTION: { SDL_Keymod keymod = SDL_GetModState(); @@ -736,31 +740,28 @@ bool CIrrDeviceSDL::run() irrevent.MouseInput.ButtonStates = MouseButtonStates; irrevent.MouseInput.Shift = (keymod & KMOD_SHIFT) != 0; irrevent.MouseInput.Control = (keymod & KMOD_CTRL) != 0; - - postEventFromUser(irrevent); break; } case SDL_MOUSEWHEEL: { +#if SDL_VERSION_ATLEAST(2, 0, 18) + double wheel = SDL_event.wheel.preciseY; +#else + double wheel = SDL_event.wheel.y; +#endif + // wheel y can be 0 if scrolling sideways + if (wheel == 0.0f) + break; + SDL_Keymod keymod = SDL_GetModState(); irrevent.EventType = irr::EET_MOUSE_INPUT_EVENT; irrevent.MouseInput.Event = irr::EMIE_MOUSE_WHEEL; -#if SDL_VERSION_ATLEAST(2, 0, 18) - irrevent.MouseInput.Wheel = SDL_event.wheel.preciseY; -#else - irrevent.MouseInput.Wheel = SDL_event.wheel.y; -#endif + irrevent.MouseInput.Wheel = wheel; irrevent.MouseInput.ButtonStates = MouseButtonStates; irrevent.MouseInput.Shift = (keymod & KMOD_SHIFT) != 0; irrevent.MouseInput.Control = (keymod & KMOD_CTRL) != 0; irrevent.MouseInput.X = MouseX; irrevent.MouseInput.Y = MouseY; - - // wheel y can be 0 if scrolling sideways - if (irrevent.MouseInput.Wheel == 0.0f) - break; - - postEventFromUser(irrevent); break; } case SDL_MOUSEBUTTONDOWN: @@ -854,16 +855,22 @@ bool CIrrDeviceSDL::run() irrevent.MouseInput.Y = static_cast(SDL_event.button.y * ScaleY); irrevent.MouseInput.Shift = shift; irrevent.MouseInput.Control = control; - postEventFromUser(irrevent); if (irrevent.MouseInput.Event >= EMIE_LMOUSE_PRESSED_DOWN && irrevent.MouseInput.Event <= EMIE_MMOUSE_PRESSED_DOWN) { u32 clicks = checkSuccessiveClicks(irrevent.MouseInput.X, irrevent.MouseInput.Y, irrevent.MouseInput.Event); if (clicks == 2) { + // Since we need to send two events, explicitly send the first + // event and clear out the SdlEvent field for the second event so + // we don't get duplicate SDL events. + postEventFromUser(irrevent); + irrevent.SdlEvent = nullptr; + irrevent.MouseInput.Event = (EMOUSE_INPUT_EVENT)(EMIE_LMOUSE_DOUBLE_CLICK + irrevent.MouseInput.Event - EMIE_LMOUSE_PRESSED_DOWN); - postEventFromUser(irrevent); } else if (clicks == 3) { - irrevent.MouseInput.Event = (EMOUSE_INPUT_EVENT)(EMIE_LMOUSE_TRIPLE_CLICK + irrevent.MouseInput.Event - EMIE_LMOUSE_PRESSED_DOWN); postEventFromUser(irrevent); + irrevent.SdlEvent = nullptr; + + irrevent.MouseInput.Event = (EMOUSE_INPUT_EVENT)(EMIE_LMOUSE_TRIPLE_CLICK + irrevent.MouseInput.Event - EMIE_LMOUSE_PRESSED_DOWN); } } } else if (irrevent.EventType == irr::EET_KEY_INPUT_EVENT) { @@ -871,7 +878,6 @@ bool CIrrDeviceSDL::run() irrevent.KeyInput.PressedDown = SDL_event.type == SDL_MOUSEBUTTONDOWN; irrevent.KeyInput.Shift = shift; irrevent.KeyInput.Control = control; - postEventFromUser(irrevent); } break; } @@ -880,9 +886,6 @@ bool CIrrDeviceSDL::run() irrevent.EventType = irr::EET_STRING_INPUT_EVENT; irrevent.StringInput.Str = new core::stringw(); irr::core::utf8ToWString(*irrevent.StringInput.Str, SDL_event.text.text); - postEventFromUser(irrevent); - delete irrevent.StringInput.Str; - irrevent.StringInput.Str = NULL; } break; case SDL_KEYDOWN: @@ -914,8 +917,6 @@ bool CIrrDeviceSDL::run() irrevent.KeyInput.Char = findCharToPassToIrrlicht(keysym, key, (SDL_event.key.keysym.mod & KMOD_NUM) != 0); irrevent.KeyInput.SystemKeyCode = scancode; - - postEventFromUser(irrevent); } break; case SDL_QUIT: @@ -939,7 +940,6 @@ bool CIrrDeviceSDL::run() if (old_scale_x != ScaleX || old_scale_y != ScaleY) { irrevent.EventType = EET_APPLICATION_EVENT; irrevent.ApplicationEvent.EventType = EAET_DPI_CHANGED; - postEventFromUser(irrevent); } break; } @@ -949,8 +949,6 @@ bool CIrrDeviceSDL::run() irrevent.EventType = irr::EET_USER_EVENT; irrevent.UserEvent.UserData1 = reinterpret_cast(SDL_event.user.data1); irrevent.UserEvent.UserData2 = reinterpret_cast(SDL_event.user.data2); - - postEventFromUser(irrevent); break; case SDL_FINGERDOWN: @@ -961,8 +959,6 @@ bool CIrrDeviceSDL::run() irrevent.TouchInput.Y = static_cast(SDL_event.tfinger.y * Height); CurrentTouchCount++; irrevent.TouchInput.touchedCount = CurrentTouchCount; - - postEventFromUser(irrevent); break; case SDL_FINGERMOTION: @@ -972,8 +968,6 @@ bool CIrrDeviceSDL::run() irrevent.TouchInput.X = static_cast(SDL_event.tfinger.x * Width); irrevent.TouchInput.Y = static_cast(SDL_event.tfinger.y * Height); irrevent.TouchInput.touchedCount = CurrentTouchCount; - - postEventFromUser(irrevent); break; case SDL_FINGERUP: @@ -988,8 +982,6 @@ bool CIrrDeviceSDL::run() if (CurrentTouchCount > 0) { CurrentTouchCount--; } - - postEventFromUser(irrevent); break; // Contrary to what the SDL documentation says, SDL_APP_WILLENTERBACKGROUND @@ -1017,6 +1009,14 @@ bool CIrrDeviceSDL::run() default: break; } // end switch + + postEventFromUser(irrevent); + + if (SDL_event.type == SDL_TEXTINPUT) { + delete irrevent.StringInput.Str; + irrevent.StringInput.Str = nullptr; + } + resetReceiveTextInputEvents(); } // end while diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bc87ca070..9f7a40162 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -529,6 +529,10 @@ if (BUILD_CLIENT) add_subdirectory(client) add_subdirectory(gui) add_subdirectory(irrlicht_changes) + + if(BUILD_UI) + add_subdirectory(ui) + endif() endif(BUILD_CLIENT) list(APPEND client_SRCS @@ -542,10 +546,12 @@ list(APPEND client_SRCS if(BUILD_UNITTESTS) list(APPEND client_SRCS ${UNITTEST_CLIENT_SRCS}) endif() - if(BUILD_BENCHMARKS) list(APPEND client_SRCS ${BENCHMARK_CLIENT_SRCS}) endif() +if(BUILD_UI) + set(client_SRCS ${client_SRCS} ${ui_SRCS}) +endif() # Server sources # (nothing here because a client always comes with a server) @@ -587,6 +593,10 @@ if(BUILD_CLIENT) ${FREETYPE_INCLUDE_DIRS} ${SOUND_INCLUDE_DIRS} ) + + if(BUILD_UI) + include_directories(SYSTEM ${SDL2_INCLUDE_DIRS}) + endif() endif() if(USE_CURL) @@ -756,6 +766,9 @@ if(BUILD_CLIENT) if(BUILD_WITH_TRACY) target_link_libraries(${PROJECT_NAME} Tracy::TracyClient) endif() + if(BUILD_UI) + target_link_libraries(${PROJECT_NAME} ${SDL2_LIBRARIES}) + endif() if(PRECOMPILE_HEADERS) target_precompile_headers(${PROJECT_NAME} PRIVATE ${PRECOMPILED_HEADERS_LIST}) diff --git a/src/client/client.cpp b/src/client/client.cpp index 3fd0983f0..a58009d97 100644 --- a/src/client/client.cpp +++ b/src/client/client.cpp @@ -1283,6 +1283,14 @@ void Client::sendInventoryFields(const std::string &formname, Send(&pkt); } +void Client::sendUiMessage(const char *data, size_t len) +{ + NetworkPacket pkt(TOSERVER_UI_MESSAGE, 0); + pkt.putRawString(data, len); + + Send(&pkt); +} + void Client::sendInventoryAction(InventoryAction *a) { std::ostringstream os(std::ios_base::binary); diff --git a/src/client/client.h b/src/client/client.h index 12625f24e..a8e6909a8 100644 --- a/src/client/client.h +++ b/src/client/client.h @@ -192,6 +192,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); @@ -230,6 +231,7 @@ public: const StringMap &fields); void sendInventoryFields(const std::string &formname, const StringMap &fields); + void sendUiMessage(const char *data, size_t len); void sendInventoryAction(InventoryAction *a); void sendChatMessage(const std::wstring &message); void clearOutChatQueue(); diff --git a/src/client/clientevent.h b/src/client/clientevent.h index 3d627c934..e4c5fe973 100644 --- a/src/client/clientevent.h +++ b/src/client/clientevent.h @@ -24,6 +24,7 @@ enum ClientEventType : u8 CE_SHOW_FORMSPEC, CE_SHOW_CSM_FORMSPEC, CE_SHOW_PAUSE_MENU_FORMSPEC, + CE_UI_MESSAGE, CE_SPAWN_PARTICLE, CE_ADD_PARTICLESPAWNER, CE_DELETE_PARTICLESPAWNER, @@ -90,6 +91,10 @@ struct ClientEvent std::string *formspec; std::string *formname; } show_formspec; + struct + { + std::string *data; + } ui_message; ParticleParameters *spawn_particle; struct { diff --git a/src/client/game.cpp b/src/client/game.cpp index ebfc3f1c8..629aae74c 100644 --- a/src/client/game.cpp +++ b/src/client/game.cpp @@ -64,6 +64,9 @@ #if USE_SOUND #include "client/sound/sound_openal.h" #endif +#if BUILD_UI +#include "ui/manager.h" +#endif #include @@ -727,6 +730,7 @@ private: void handleClientEvent_ShowFormSpec(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_ShowCSMFormSpec(ClientEvent *event, CameraOrientation *cam); void handleClientEvent_ShowPauseMenuFormSpec(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); @@ -777,6 +781,9 @@ private: std::unique_ptr m_game_ui; irr_ptr gui_chat_console; +#if BUILD_UI + irr_ptr gui_manager_elem; +#endif MapDrawControl *draw_control = nullptr; Camera *camera = nullptr; irr_ptr clouds; @@ -1117,6 +1124,10 @@ void Game::run() void Game::shutdown() { // Delete text and menus first +#if BUILD_UI + ui::g_manager.reset(); +#endif + m_game_ui->clearText(); m_game_formspec.reset(); while (g_menumgr.menuCount() > 0) { @@ -1129,6 +1140,9 @@ void Game::shutdown() clouds.reset(); gui_chat_console.reset(); +#if BUILD_UI + gui_manager_elem.reset(); +#endif sky.reset(); @@ -1426,6 +1440,10 @@ bool Game::createClient(const GameStartData &start_data) if (mapper && client->modsLoaded()) client->getScript()->on_minimap_ready(mapper); +#if BUILD_UI + ui::g_manager.setClient(client); +#endif + return true; } @@ -1454,6 +1472,12 @@ bool Game::initGui() gui_chat_console = make_irr(guienv, guienv->getRootGUIElement(), -1, chat_backend, client, &g_menumgr); +#if BUILD_UI + // Thingy to draw UI manager after chat but before formspecs. + gui_manager_elem = make_irr( + guienv, guienv->getRootGUIElement(), -1); +#endif + if (shouldShowTouchControls()) g_touchcontrols = new TouchControls(device, texture_src); @@ -2653,6 +2677,7 @@ const ClientEventHandler Game::clientEventHandler[CLIENTEVENT_MAX] = { {&Game::handleClientEvent_ShowFormSpec}, {&Game::handleClientEvent_ShowCSMFormSpec}, {&Game::handleClientEvent_ShowPauseMenuFormSpec}, + {&Game::handleClientEvent_UiMessage}, {&Game::handleClientEvent_HandleParticleEvent}, {&Game::handleClientEvent_HandleParticleEvent}, {&Game::handleClientEvent_HandleParticleEvent}, @@ -2744,6 +2769,14 @@ void Game::handleClientEvent_ShowPauseMenuFormSpec(ClientEvent *event, CameraOri delete event->show_formspec.formname; } +void Game::handleClientEvent_UiMessage(ClientEvent *event, CameraOrientation *cam) +{ +#if BUILD_UI + ui::g_manager.receiveMessage(*event->ui_message.data); +#endif + delete event->ui_message.data; +} + void Game::handleClientEvent_HandleParticleEvent(ClientEvent *event, CameraOrientation *cam) { @@ -4156,7 +4189,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/gameui.cpp b/src/client/gameui.cpp index 53fae50c0..ff8a64586 100644 --- a/src/client/gameui.cpp +++ b/src/client/gameui.cpp @@ -163,7 +163,7 @@ void GameUI::update(const RunStats &stats, Client *client, MapDrawControl *draw_ m_guitext2->setVisible(m_flags.show_basic_debug); setStaticText(m_guitext_info, m_infotext.c_str()); - m_guitext_info->setVisible(m_flags.show_hud && g_menumgr.menuCount() == 0); + m_guitext_info->setVisible(m_flags.show_hud && !isMenuActive()); static const float statustext_time_max = 1.5f; diff --git a/src/client/render/core.cpp b/src/client/render/core.cpp index ef780269a..e380eebe9 100644 --- a/src/client/render/core.cpp +++ b/src/client/render/core.cpp @@ -21,7 +21,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(); @@ -31,6 +31,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 f9d92bee5..515c0864e 100644 --- a/src/client/render/core.h +++ b/src/client/render/core.h @@ -45,7 +45,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 d91d523b9..b09ffdb16 100644 --- a/src/client/render/pipeline.h +++ b/src/client/render/pipeline.h @@ -38,6 +38,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 0f94e3ef0..a26fe218d 100644 --- a/src/client/render/plain.cpp +++ b/src/client/render/plain.cpp @@ -13,6 +13,10 @@ #include "client/shadows/dynamicshadowsrender.h" #include +#if BUILD_UI +#include "ui/manager.h" +#endif + /// Draw3D pipeline step void Draw3D::run(PipelineContext &context) { @@ -29,6 +33,11 @@ void Draw3D::run(PipelineContext &context) void DrawWield::run(PipelineContext &context) { +#if BUILD_UI + ui::g_manager.preDraw(); + ui::g_manager.drawType(ui::WindowType::FILTER); +#endif + if (m_target) m_target->activate(context); @@ -46,10 +55,26 @@ void DrawHUD::run(PipelineContext &context) if (context.draw_crosshair) context.hud->drawCrosshair(); + } +#if BUILD_UI + ui::g_manager.drawType(ui::WindowType::MASK); +#endif + + if (context.show_hud) { context.hud->drawLuaElements(context.client->getCamera()->getOffset()); +#if BUILD_UI + ui::g_manager.drawType(ui::WindowType::HUD); +#endif + context.client->getCamera()->drawNametags(); } + +#if BUILD_UI + if (context.show_chat) + ui::g_manager.drawType(ui::WindowType::CHAT); +#endif + context.device->getGUIEnvironment()->drawAll(); } diff --git a/src/client/renderingengine.cpp b/src/client/renderingengine.cpp index 3b3d114ea..1358b9fa2 100644 --- a/src/client/renderingengine.cpp +++ b/src/client/renderingengine.cpp @@ -401,10 +401,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 34918ec7a..91165fde8 100644 --- a/src/client/renderingengine.h +++ b/src/client/renderingengine.h @@ -129,7 +129,7 @@ public: gui::IGUIEnvironment *guienv, ITextureSource *tsrc, float dtime = 0, int percent = 0, float *indef_pos = nullptr); - 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/cmake_config.h.in b/src/cmake_config.h.in index 2ec91dfd1..492166ae2 100644 --- a/src/cmake_config.h.in +++ b/src/cmake_config.h.in @@ -41,5 +41,6 @@ #cmakedefine01 CURSES_HAVE_NCURSESW_CURSES_H #cmakedefine01 BUILD_UNITTESTS #cmakedefine01 BUILD_BENCHMARKS +#cmakedefine01 BUILD_UI #cmakedefine01 USE_SDL2 #cmakedefine01 BUILD_WITH_TRACY diff --git a/src/gui/mainmenumanager.h b/src/gui/mainmenumanager.h index 553d6ffce..b4d51172e 100644 --- a/src/gui/mainmenumanager.h +++ b/src/gui/mainmenumanager.h @@ -7,12 +7,17 @@ /* All kinds of stuff that needs to be exposed from main.cpp */ +#include "config.h" #include "modalMenu.h" #include #include #include "IGUIEnvironment.h" +#if BUILD_UI +#include "ui/manager.h" +#endif + namespace irr::gui { class IGUIStaticText; } @@ -67,10 +72,16 @@ public: // Returns true to prevent further processing virtual bool preprocessEvent(const SEvent& event) { - if (m_stack.empty()) - return false; - GUIModalMenu *mm = dynamic_cast(m_stack.back()); - return mm && mm->preprocessEvent(event); + if (!m_stack.empty()) { + GUIModalMenu *mm = dynamic_cast(m_stack.back()); + return mm && mm->preprocessEvent(event); +#if BUILD_UI + } else if (ui::g_manager.isFocused() && event.SdlEvent != nullptr) { + return ui::g_manager.processInput(*event.SdlEvent); +#endif + } + + return false; } size_t menuCount() const @@ -109,7 +120,11 @@ extern MainMenuManager g_menumgr; static inline bool isMenuActive() { +#if BUILD_UI + return g_menumgr.menuCount() != 0 || ui::g_manager.isFocused(); +#else return g_menumgr.menuCount() != 0; +#endif } class MainGameCallback : public IGameCallback diff --git a/src/network/clientopcodes.cpp b/src/network/clientopcodes.cpp index 9a9cb5968..9c4c5c2f9 100644 --- a/src/network/clientopcodes.cpp +++ b/src/network/clientopcodes.cpp @@ -105,7 +105,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, @@ -188,7 +188,7 @@ const ServerCommandFactory serverCommandFactoryTable[TOSERVER_NUM_MSG_TYPES] = { "TOSERVER_REMOVED_SOUNDS", 2, true }, // 0x3a { "TOSERVER_NODEMETA_FIELDS", 0, true }, // 0x3b { "TOSERVER_INVENTORY_FIELDS", 0, true }, // 0x3c - null_command_factory, // 0x3d + { "TOSERVER_UI_MESSAGE", 0, true }, // 0x3d null_command_factory, // 0x3e null_command_factory, // 0x3f { "TOSERVER_REQUEST_MEDIA", 1, true }, // 0x40 diff --git a/src/network/clientpackethandler.cpp b/src/network/clientpackethandler.cpp index 6cd7150c6..c588e44b7 100644 --- a/src/network/clientpackethandler.cpp +++ b/src/network/clientpackethandler.cpp @@ -954,6 +954,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.cpp b/src/network/networkprotocol.cpp index a88b5b091..d8388b190 100644 --- a/src/network/networkprotocol.cpp +++ b/src/network/networkprotocol.cpp @@ -59,8 +59,9 @@ Rename TOSERVER_RESPAWN to TOSERVER_RESPAWN_LEGACY Support float animation frame numbers in TOCLIENT_LOCAL_PLAYER_ANIMATIONS [scheduled bump for 5.10.0] - PROTOCOL VERSION 47 + PROTOCOL VERSION 47: Add particle blend mode "clip" + Add TOCLIENT_UI_MESSAGE and TOSERVER_UI_MESSAGE [scheduled bump for 5.11.0] PROTOCOL VERSION 48 Add compression to some existing packets diff --git a/src/network/networkprotocol.h b/src/network/networkprotocol.h index 5ce3f4221..ca1f54e80 100644 --- a/src/network/networkprotocol.h +++ b/src/network/networkprotocol.h @@ -648,6 +648,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. @@ -832,6 +838,11 @@ enum ToServerCommand : u16 u8[len] field value */ + TOSERVER_UI_MESSAGE = 0x3d, + /* + Variable-length structure that changes depending on the message type. + */ + TOSERVER_REQUEST_MEDIA = 0x40, /* u16 number of files requested diff --git a/src/network/serveropcodes.cpp b/src/network/serveropcodes.cpp index f75e1f5cd..c0f86d69b 100644 --- a/src/network/serveropcodes.cpp +++ b/src/network/serveropcodes.cpp @@ -72,7 +72,7 @@ const ToServerCommandHandler toServerCommandTable[TOSERVER_NUM_MSG_TYPES] = { "TOSERVER_REMOVED_SOUNDS", TOSERVER_STATE_INGAME, &Server::handleCommand_RemovedSounds }, // 0x3a { "TOSERVER_NODEMETA_FIELDS", TOSERVER_STATE_INGAME, &Server::handleCommand_NodeMetaFields }, // 0x3b { "TOSERVER_INVENTORY_FIELDS", TOSERVER_STATE_INGAME, &Server::handleCommand_InventoryFields }, // 0x3c - null_command_handler, // 0x3d + { "TOSERVER_UI_MESSAGE", TOSERVER_STATE_INGAME, &Server::handleCommand_UiMessage }, // 0x3d null_command_handler, // 0x3e null_command_handler, // 0x3f { "TOSERVER_REQUEST_MEDIA", TOSERVER_STATE_STARTUP, &Server::handleCommand_RequestMedia }, // 0x40 @@ -206,7 +206,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/network/serverpackethandler.cpp b/src/network/serverpackethandler.cpp index e9012d351..dfa1cd4af 100644 --- a/src/network/serverpackethandler.cpp +++ b/src/network/serverpackethandler.cpp @@ -1422,6 +1422,15 @@ void Server::handleCommand_InventoryFields(NetworkPacket* pkt) actionstream << ", possible exploitation attempt" << std::endl; } +void Server::handleCommand_UiMessage(NetworkPacket* pkt) +{ + session_t peer_id = pkt->getPeerId(); + RemoteClient *client = getClient(peer_id, CS_Invalid); + + std::string data(pkt->getString(0), pkt->getSize()); + m_script->receive_ui_message(client->getName().c_str(), data); +} + void Server::handleCommand_FirstSrp(NetworkPacket* pkt) { session_t peer_id = pkt->getPeerId(); diff --git a/src/script/cpp_api/s_server.cpp b/src/script/cpp_api/s_server.cpp index faacf9714..88ba2e5d4 100644 --- a/src/script/cpp_api/s_server.cpp +++ b/src/script/cpp_api/s_server.cpp @@ -245,3 +245,18 @@ void ScriptApiServer::on_dynamic_media_added(u32 token, const std::string &playe lua_pushstring(L, playername.c_str()); PCALL_RES(lua_pcall(L, 1, 0, error_handler)); } + +void ScriptApiServer::receive_ui_message(const char *name, const std::string &data) +{ + SCRIPTAPI_PRECHECKHEADER + + int error_handler = PUSH_ERROR_HANDLER(L); + + lua_getglobal(L, "core"); + lua_getfield(L, -1, "receive_ui_message"); + + lua_pushstring(L, name); + lua_pushlstring(L, data.c_str(), data.size()); + + PCALL_RES(lua_pcall(L, 2, 0, error_handler)); +} diff --git a/src/script/cpp_api/s_server.h b/src/script/cpp_api/s_server.h index 148cdfa84..18cacc9e6 100644 --- a/src/script/cpp_api/s_server.h +++ b/src/script/cpp_api/s_server.h @@ -40,6 +40,8 @@ public: void freeDynamicMediaCallback(u32 token); void on_dynamic_media_added(u32 token, const std::string &playername); + void receive_ui_message(const char *name, const std::string &data); + private: void getAuthHandler(); void readPrivileges(int index, std::set &result); diff --git a/src/script/lua_api/l_server.cpp b/src/script/lua_api/l_server.cpp index 698b2dba6..779ae1d26 100644 --- a/src/script/lua_api/l_server.cpp +++ b/src/script/lua_api/l_server.cpp @@ -418,6 +418,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) { @@ -730,6 +743,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 6de7de363..6c3267045 100644 --- a/src/script/lua_api/l_server.h +++ b/src/script/lua_api/l_server.h @@ -55,6 +55,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/script/lua_api/l_util.cpp b/src/script/lua_api/l_util.cpp index 5ac290b2e..740c6b2e4 100644 --- a/src/script/lua_api/l_util.cpp +++ b/src/script/lua_api/l_util.cpp @@ -585,10 +585,10 @@ int ModApiUtil::l_colorspec_to_colorstring(lua_State *L) snprintf(colorstring, 10, "#%02X%02X%02X%02X", color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha()); lua_pushstring(L, colorstring); - return 1; + } else { + lua_pushnil(L); } - - return 0; + return 1; } // colorspec_to_bytes(colorspec) @@ -605,10 +605,10 @@ int ModApiUtil::l_colorspec_to_bytes(lua_State *L) (u8) color.getAlpha(), }; lua_pushlstring(L, (const char*) colorbytes, 4); - return 1; + } else { + lua_pushnil(L); } - - return 0; + return 1; } // colorspec_to_table(colorspec) @@ -619,10 +619,201 @@ int ModApiUtil::l_colorspec_to_table(lua_State *L) video::SColor color(0); if (read_color(L, 1, &color)) { push_ARGB8(L, color); - return 1; + } else { + lua_pushnil(L); + } + return 1; +} + +// colorspec_to_int(colorspec) +int ModApiUtil::l_colorspec_to_int(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + + video::SColor color(0); + if (read_color(L, 1, &color)) { + lua_pushnumber(L, color.color); + } else { + lua_pushnil(L); + } + return 1; +} + +// 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 << std::string_view(str.c_str(), 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++; } - return 0; + 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; } // time_to_day_night_ratio(time_of_day) @@ -737,8 +928,12 @@ void ModApiUtil::Initialize(lua_State *L, int top) API_FCT(colorspec_to_colorstring); API_FCT(colorspec_to_bytes); API_FCT(colorspec_to_table); - API_FCT(time_to_day_night_ratio); + API_FCT(colorspec_to_int); + API_FCT(encode_network); + API_FCT(decode_network); + + API_FCT(time_to_day_night_ratio); API_FCT(encode_png); API_FCT(get_last_run_mod); @@ -774,6 +969,11 @@ void ModApiUtil::InitializeClient(lua_State *L, int top) API_FCT(colorspec_to_colorstring); API_FCT(colorspec_to_bytes); API_FCT(colorspec_to_table); + API_FCT(colorspec_to_int); + + API_FCT(encode_network); + API_FCT(decode_network); + API_FCT(time_to_day_night_ratio); API_FCT(get_last_run_mod); @@ -820,8 +1020,12 @@ void ModApiUtil::InitializeAsync(lua_State *L, int top) API_FCT(colorspec_to_colorstring); API_FCT(colorspec_to_bytes); API_FCT(colorspec_to_table); - API_FCT(time_to_day_night_ratio); + API_FCT(colorspec_to_int); + API_FCT(encode_network); + API_FCT(decode_network); + + API_FCT(time_to_day_night_ratio); 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 0df2c3ae4..3686255a6 100644 --- a/src/script/lua_api/l_util.h +++ b/src/script/lua_api/l_util.h @@ -5,6 +5,7 @@ #pragma once #include "lua_api/l_base.h" +#include "util/serialize.h" class AsyncEngine; @@ -110,6 +111,15 @@ private: // colorspec_to_table(colorspec) static int l_colorspec_to_table(lua_State *L); + // colorspec_to_int(colorspec) + static int l_colorspec_to_int(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); + // time_to_day_night_ratio(time_of_day) static int l_time_to_day_night_ratio(lua_State *L); diff --git a/src/server.cpp b/src/server.cpp index 16434f447..35f1ff316 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -3403,6 +3403,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 177af002f..a1fb12e77 100644 --- a/src/server.h +++ b/src/server.h @@ -227,6 +227,7 @@ public: void handleCommand_RemovedSounds(NetworkPacket* pkt); void handleCommand_NodeMetaFields(NetworkPacket* pkt); void handleCommand_InventoryFields(NetworkPacket* pkt); + void handleCommand_UiMessage(NetworkPacket* pkt); void handleCommand_FirstSrp(NetworkPacket* pkt); void handleCommand_SrpBytesA(NetworkPacket* pkt); void handleCommand_SrpBytesM(NetworkPacket* pkt); @@ -357,6 +358,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(); diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt new file mode 100644 index 000000000..f83ac19c4 --- /dev/null +++ b/src/ui/CMakeLists.txt @@ -0,0 +1,10 @@ +set(ui_SRCS + ${CMAKE_CURRENT_SOURCE_DIR}/box.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/clickable_elems.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/elem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/manager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/static_elems.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/style.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/window.cpp + PARENT_SCOPE +) diff --git a/src/ui/box.cpp b/src/ui/box.cpp new file mode 100644 index 000000000..c4bcec3e0 --- /dev/null +++ b/src/ui/box.cpp @@ -0,0 +1,692 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#include "ui/box.h" + +#include "debug.h" +#include "log.h" +#include "porting.h" +#include "client/fontengine.h" +#include "ui/elem.h" +#include "ui/manager.h" +#include "ui/window.h" +#include "util/serialize.h" + +#include + +namespace ui +{ + Window &Box::getWindow() + { + return m_elem.getWindow(); + } + + const Window &Box::getWindow() const + { + return m_elem.getWindow(); + } + + void Box::reset() + { + m_content.clear(); + m_label = ""; + + m_style.reset(); + + for (State i = 0; i < m_style_refs.size(); i++) { + m_style_refs[i] = NO_STYLE; + } + + m_text = L""; + m_font = nullptr; + } + + 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 index = readU32(is); + if (getWindow().getStyleStr(index) != nullptr) { + m_style_refs[i] = index; + } else { + errorstream << "Style " << index << " does not exist" << std::endl; + } + } + } + + void Box::restyle() + { + // First, clear our current style and compute what state we're in. + m_style.reset(); + State state = STATE_NONE; + + if (m_elem.isBoxFocused(*this)) + state |= STATE_FOCUSED; + if (m_elem.isBoxSelected(*this)) + state |= STATE_SELECTED; + if (m_elem.isBoxHovered(*this)) + state |= STATE_HOVERED; + if (m_elem.isBoxPressed(*this)) + state |= STATE_PRESSED; + if (m_elem.isBoxDisabled(*this)) + state |= STATE_DISABLED; + + // 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); + } + } + + // Now that we have updated text style properties, we can update our + // cached text string and font object. + m_text = utf8_to_wide(m_style.text.prepend) + utf8_to_wide(m_label) + + utf8_to_wide(m_style.text.append); + + FontSpec spec(m_style.text.size, m_style.text.mono ? FM_Mono : FM_Standard, + m_style.text.bold, m_style.text.italic); + m_font = g_fontengine->getFont(spec); + + // Since our box has been restyled, the previously computed layout + // information is no longer valid. + m_min_layout = SizeF(); + m_min_content = SizeF(); + + m_display_rect = RectF(); + m_icon_rect = RectF(); + m_content_rect = RectF(); + + m_clip_rect = RectF(); + + // Finally, make sure to restyle our content. + for (Box *box : m_content) { + box->restyle(); + } + } + + void Box::resize() + { + for (Box *box : m_content) { + box->resize(); + } + + switch (m_style.layout.type) { + case LayoutType::PLACE: + resizePlace(); + break; + } + + resizeBox(); + } + + void Box::relayout(RectF layout_rect, RectF layout_clip) + { + relayoutBox(layout_rect, layout_clip); + + switch (m_style.layout.type) { + case LayoutType::PLACE: + relayoutPlace(); + break; + } + } + + void Box::draw() + { + if (m_style.display != DisplayMode::HIDDEN) { + drawBox(); + drawItems(); + } + + for (Box *box : m_content) { + box->draw(); + } + } + + bool Box::isPointed() const + { + return m_clip_rect.contains(getWindow().getPointerPos()); + } + + bool Box::isContentPointed() const { + // If we're pointed, then we clearly have a pointed box. + if (isPointed()) { + return true; + } + + // Search through our content. If any of them are contained within the + // same element as this box, they are candidates for being pointed. + for (Box *box : m_content) { + if (&box->getElem() == &m_elem && box->isContentPointed()) { + return true; + } + } + + return false; + } + + bool Box::processInput(const SDL_Event &event) + { + switch (event.type) { + case UI_USER(FOCUS_REQUEST): + // The box is dynamic, so it can be focused. + return true; + + case UI_USER(FOCUS_CHANGED): + // If the box is no longer focused, it can't be pressed. + if (event.user.data1 == &m_elem) { + setPressed(false); + } + return false; + + case UI_USER(FOCUS_SUBVERTED): + // If some non-focused element used an event instead of this one, + // unpress the box because user interaction has been diverted. + setPressed(false); + return false; + + case UI_USER(HOVER_REQUEST): + // The box can be hovered if the pointer is inside it. + return isPointed(); + + case UI_USER(HOVER_CHANGED): + // Make this box hovered if the element became hovered and the + // pointer is inside this box. + setHovered(event.user.data2 == &m_elem && isPointed()); + return true; + + default: + return false; + } + } + + bool Box::processFullPress(const SDL_Event &event, void (*on_press)(Elem &)) + { + switch (event.type) { + case SDL_KEYDOWN: + // If the space key is pressed not due to a key repeat, then the + // box becomes pressed. If the escape key is pressed while the box + // is pressed, that unpresses the box without triggering it. + if (event.key.keysym.sym == SDLK_SPACE && !event.key.repeat) { + setPressed(true); + return true; + } else if (event.key.keysym.sym == SDLK_ESCAPE && isPressed()) { + setPressed(false); + return true; + } + return false; + + case SDL_KEYUP: + // Releasing the space key while the box is pressed causes it to be + // unpressed and triggered. + if (event.key.keysym.sym == SDLK_SPACE && isPressed()) { + setPressed(false); + on_press(m_elem); + return true; + } + return false; + + case SDL_MOUSEBUTTONDOWN: + // If the box is hovered, then pressing the left mouse button + // causes it to be pressed. Otherwise, the mouse is directed at + // some other box. + if (isHovered() && event.button.button == SDL_BUTTON_LEFT) { + setPressed(true); + return true; + } + return false; + + case SDL_MOUSEBUTTONUP: + // If the mouse button was released, the box becomes unpressed. If + // it was released while inside the bounds of the box, that counts + // as the box being triggered. + if (event.button.button == SDL_BUTTON_LEFT) { + bool was_pressed = isPressed(); + setPressed(false); + + if (isHovered() && was_pressed) { + on_press(m_elem); + return true; + } + } + return false; + + default: + return processInput(event); + } + } + + RectF Box::getLayerSource(const Layer &layer) + { + RectF src = layer.source; + + // If we have animations, we need to adjust the source rect by the + // frame offset in accordance with the current frame. + if (layer.num_frames > 1) { + float frame_height = src.H() / layer.num_frames; + src.B = src.T + frame_height; + + float frame_offset = frame_height * + ((porting::getTimeMs() / layer.frame_time) % layer.num_frames); + src.T += frame_offset; + src.B += frame_offset; + } + + return src; + } + + SizeF Box::getLayerSize(const Layer &layer) + { + return getLayerSource(layer).size() * getTextureSize(layer.image); + } + + DispF Box::getMiddleEdges() + { + // Scale the middle rect by the scaling factor and de-normalize it into + // actual pixels based on the image source rect. + return m_style.box_middle * DispF(getLayerSize(m_style.box)) * m_style.box.scale; + } + + void Box::resizeBox() + { + // First, we need to expand the minimum size of the box to accommodate + // the size of any text it might contain. + SizeF text_size = getWindow().getTextSize(m_font, m_text); + m_min_content = m_min_content.max(text_size); + + // If the box is set to clip its contents in either dimension, we can + // set the minimum content size to zero for that coordinate. + if (m_style.layout.clip == DirFlags::X || m_style.layout.clip == DirFlags::BOTH) { + m_min_content.W = 0.0f; + } + if (m_style.layout.clip == DirFlags::Y || m_style.layout.clip == DirFlags::BOTH) { + m_min_content.H = 0.0f; + } + + // We need to factor the icon into the minimum size of the box. The + // minimum size of the padding rect is either the size of the contents + // or the scaled icon, depending on which is larger. If the scale is + // zero, then the icon doesn't add anything to the minimum size. + SizeF icon_size = getLayerSize(m_style.icon) * m_style.icon.scale; + SizeF padding_size = m_min_content.max(icon_size); + + // If the icon should not overlap the content, then we must take into + // account the extra space required for this, including the gutter. + if (!m_style.icon_overlap && m_style.icon.image != nullptr) { + switch (m_style.icon_place) { + case IconPlace::CENTER: + break; + case IconPlace::LEFT: + case IconPlace::RIGHT: + padding_size.W = m_min_content.W + icon_size.W + m_style.icon_gutter; + break; + case IconPlace::TOP: + case IconPlace::BOTTOM: + padding_size.H = m_min_content.H + icon_size.H + m_style.icon_gutter; + break; + } + + padding_size = padding_size.clip(); + } + + // Now that we have a minimum size for the padding rect, we can + // calculate the display rect size by adjusting for the padding and + // middle rect edges. We also clamp the size of the display rect to be + // at least as large as the user-specified minimum size. + SizeF display_size = (padding_size + getMiddleEdges().extents() + + m_style.sizing.padding.extents()).max(m_style.sizing.size); + + // The final minimum size is the display size adjusted for the margin. + m_min_layout = (display_size + m_style.sizing.margin.extents()).clip(); + } + + void Box::relayoutBox(RectF layout_rect, RectF layout_clip) + { + // The display rect is created by insetting the layout rect by the + // margin. The padding rect is inset from that by the middle rect edges + // and the padding. We must make sure these do not have negative sizes. + m_display_rect = layout_rect.insetBy(m_style.sizing.margin).clip(); + RectF padding_rect = m_display_rect.insetBy( + getMiddleEdges() + m_style.sizing.padding).clip(); + + // The icon is aligned and scaled in a particular area of the box. + // First, get the basic size of the icon rect. + SizeF icon_size = getLayerSize(m_style.icon); + + // Then, modify it based on 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. + if (m_style.icon.scale == 0.0f) { + SizeF max_icon = padding_rect.size(); + + // If the icon should not overlap the content, then we need to + // adjust the area in which we compute the maximum scale by + // subtracting the content and gutter from the padding rect size. + if (!m_style.icon_overlap && m_style.icon.image != nullptr) { + switch (m_style.icon_place) { + case IconPlace::CENTER: + break; + case IconPlace::LEFT: + case IconPlace::RIGHT: + max_icon.W -= m_min_content.W + m_style.icon_gutter; + break; + case IconPlace::TOP: + case IconPlace::BOTTOM: + max_icon.H -= m_min_content.H + m_style.icon_gutter; + break; + } + + max_icon = max_icon.clip(); + } + + // Choose the scale factor based on the space we have for the icon, + // choosing the smaller of the two possible image size ratios. + icon_size *= std::min(max_icon.W / icon_size.W, max_icon.H / icon_size.H); + } else { + icon_size *= m_style.icon.scale; + } + + // Now that we have the size of the icon, we can compute the icon rect + // based on the desired placement of the icon. + PosF icon_start = padding_rect.TopLeft; + PosF icon_center = icon_start + (padding_rect.size() - icon_size) / 2.0f; + PosF icon_end = icon_start + (padding_rect.size() - icon_size); + + switch (m_style.icon_place) { + case IconPlace::CENTER: + m_icon_rect = RectF(icon_center, icon_size); + break; + case IconPlace::LEFT: + m_icon_rect = RectF(PosF(icon_start.X, icon_center.Y), icon_size); + break; + case IconPlace::TOP: + m_icon_rect = RectF(PosF(icon_center.X, icon_start.Y), icon_size); + break; + case IconPlace::RIGHT: + m_icon_rect = RectF(PosF(icon_end.X, icon_center.Y), icon_size); + break; + case IconPlace::BOTTOM: + m_icon_rect = RectF(PosF(icon_center.X, icon_end.Y), icon_size); + break; + } + + // If the overlap property is set or the icon is centered, the content + // rect is identical to the padding rect. Otherwise, the content rect + // needs to be adjusted to account for the icon and gutter. + m_content_rect = padding_rect; + + if (!m_style.icon_overlap && m_style.icon.image != nullptr) { + switch (m_style.icon_place) { + case IconPlace::CENTER: + break; + case IconPlace::LEFT: + m_content_rect.L += icon_size.W + m_style.icon_gutter; + break; + case IconPlace::TOP: + m_content_rect.T += icon_size.H + m_style.icon_gutter; + break; + case IconPlace::RIGHT: + m_content_rect.R -= icon_size.W + m_style.icon_gutter; + break; + case IconPlace::BOTTOM: + m_content_rect.B -= icon_size.H + m_style.icon_gutter; + break; + } + + m_content_rect = m_content_rect.clip(); + } + + // We set our clipping rect based on the display mode. + switch (m_style.display) { + case DisplayMode::VISIBLE: + case DisplayMode::HIDDEN: + // If the box is visible or hidden, then we clip the box and its + // content as normal against the drawing and layout clip rects. + m_clip_rect = m_display_rect.intersectWith(layout_clip); + break; + case DisplayMode::OVERFLOW: + // If the box allows overflow, then clip to the drawing rect, since + // we never want to expand outside our own visible boundaries, but + // we don't clip to the layout clip rect. + m_clip_rect = m_display_rect; + break; + case DisplayMode::CLIPPED: + // If the box and its content should be entirely removed, then we + // clip everything entirely. + m_clip_rect = RectF(); + break; + } + } + + void Box::resizePlace() + { + for (Box *box : m_content) { + // Calculate the size of the box according to the span and scale + // factor. If the scale is zero, we don't know how big the span + // will end up being, so the span size goes to zero. + SizeF span_size = box->m_style.sizing.span * m_style.layout.scale; + + // Ensure that the computed minimum size for our content is at + // least as large as the minimum size of the box and its span size. + m_min_content = m_min_content.max(box->m_min_layout).max(span_size); + } + } + + void Box::relayoutPlace() + { + for (Box *box : m_content) { + const Sizing &sizing = box->m_style.sizing; + + // Compute the scale factor. If the scale is zero, then we use the + // size of the parent box to achieve normalized coordinates. + SizeF scale = m_style.layout.scale == 0.0f ? + m_content_rect.size() : SizeF(m_style.layout.scale); + + // Calculate the position and size of the box relative to the + // origin, taking into account the scale factor and anchor. Also + // make sure the size doesn't go below the minimum size. + SizeF size = (sizing.span * scale).max(box->m_min_layout); + SizeF pos = (sizing.pos * scale) - (sizing.anchor * size); + + // The layout rect of the box is made by shifting the above rect by + // the top left of the content rect. + RectF layout_rect = RectF(m_content_rect.TopLeft + pos, size); + box->relayout(layout_rect, m_clip_rect); + } + } + + void Box::drawBox() + { + // First, fill the display rectangle with the fill color. + getWindow().drawRect(m_display_rect, m_clip_rect, m_style.box.fill); + + // If there's no image, then we don't need to do a bunch of + // calculations in order to draw nothing. + if (m_style.box.image == nullptr) { + return; + } + + // For the image, first get the source rect adjusted for animations. + RectF src = getLayerSource(m_style.box); + + // We need to make sure the the middle rect is relative to the source + // rect rather than the entire image, so scale the edges appropriately. + DispF middle_src = m_style.box_middle * DispF(src.size()); + DispF middle_dst = getMiddleEdges(); + + // 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. + if (src.W() < 0.0f) { + middle_src.L = -middle_src.L; + middle_src.R = -middle_src.R; + } + if (src.H() < 0.0f) { + middle_src.T = -middle_src.T; + middle_src.B = -middle_src.B; + } + + for (int slice_y = 0; slice_y < 3; slice_y++) { + for (int slice_x = 0; slice_x < 3; slice_x++) { + // Compute each slice of the nine-slice image. If the middle + // rect equals the whole source rect, the middle slice will + // occupy the entire display rectangle. + RectF slice_src = src; + RectF slice_dst = m_display_rect; + + switch (slice_x) { + case 0: + slice_dst.R = slice_dst.L + middle_dst.L; + slice_src.R = slice_src.L + middle_src.L; + break; + + case 1: + slice_dst.L += middle_dst.L; + slice_dst.R -= middle_dst.R; + slice_src.L += middle_src.L; + slice_src.R -= middle_src.R; + break; + + case 2: + slice_dst.L = slice_dst.R - middle_dst.R; + slice_src.L = slice_src.R - middle_src.R; + break; + } + + switch (slice_y) { + case 0: + slice_dst.B = slice_dst.T + middle_dst.T; + slice_src.B = slice_src.T + middle_src.T; + break; + + case 1: + slice_dst.T += middle_dst.T; + slice_dst.B -= middle_dst.B; + slice_src.T += middle_src.T; + slice_src.B -= middle_src.B; + break; + + case 2: + slice_dst.T = slice_dst.B - middle_dst.B; + slice_src.T = slice_src.B - middle_src.B; + break; + } + + // If we have a tiled image, then some of the tiles may bleed + // out of the slice rect, so we need to clip to both the + // clipping rect and the destination rect. + RectF slice_clip = m_clip_rect.intersectWith(slice_dst); + + // If this slice is empty or has been entirely clipped, then + // don't bother drawing anything. + if (slice_clip.empty()) { + continue; + } + + // This may be a tiled image, so we need to calculate the size + // of each tile. If the image is not tiled, this should equal + // the size of the destination rect. + SizeF tile_size = slice_dst.size(); + + if (m_style.box_tile != DirFlags::NONE) { + // We need to calculate the tile size based on the texture + // size and the scale of each tile. If the scale is too + // small, then the number of tiles will explode, so we + // clamp it to a reasonable minimum of 1/8 of a pixel. + SizeF tex_size = getTextureSize(m_style.box.image); + float tile_scale = std::max(m_style.box.scale, 0.125f); + + if (m_style.box_tile != DirFlags::Y) { + tile_size.W = slice_src.W() * tex_size.W * tile_scale; + } + if (m_style.box_tile != DirFlags::X) { + tile_size.H = slice_src.H() * tex_size.H * tile_scale; + } + } + + // Now we can draw each tile for this slice. If the image is + // not tiled, then each of these loops will run only once. + float tile_y = slice_dst.T; + + while (tile_y < slice_dst.B) { + float tile_x = slice_dst.L; + + while (tile_x < slice_dst.R) { + // Draw the texture in the appropriate destination rect + // for this tile, and clip it to the clipping rect for + // this slice. + RectF tile_dst = RectF(PosF(tile_x, tile_y), tile_size); + + getWindow().drawTexture(tile_dst, slice_clip, + m_style.box.image, slice_src, m_style.box.tint); + + tile_x += tile_size.W; + } + tile_y += tile_size.H; + } + } + } + } + + void Box::drawItems() + { + // The icon rect is computed while the box is being laid out, so we + // just need to draw it with the fill color behind it. + getWindow().drawRect(m_icon_rect, m_clip_rect, m_style.icon.fill); + getWindow().drawTexture(m_icon_rect, m_clip_rect, m_style.icon.image, + getLayerSource(m_style.icon), m_style.icon.tint); + + // The window handles all the complicated text layout, so we can just + // draw the text with all the appropriate styling. + getWindow().drawText(m_content_rect, m_clip_rect, m_font, m_text, + m_style.text.color, m_style.text.mark, + m_style.text.align, m_style.text.valign); + } + + bool Box::isHovered() const + { + return m_elem.getHoveredBox() == getId(); + } + + bool Box::isPressed() const + { + return m_elem.getPressedBox() == getId(); + } + + void Box::setHovered(bool hovered) + { + if (hovered) { + m_elem.setHoveredBox(getId()); + } else if (isHovered()) { + m_elem.setHoveredBox(NO_ID); + } + } + + void Box::setPressed(bool pressed) + { + if (pressed) { + m_elem.setPressedBox(getId()); + } else if (isPressed()) { + m_elem.setPressedBox(NO_ID); + } + } +} diff --git a/src/ui/box.h b/src/ui/box.h new file mode 100644 index 000000000..963a2aea8 --- /dev/null +++ b/src/ui/box.h @@ -0,0 +1,144 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#pragma once + +#include "ui/helpers.h" +#include "ui/style.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include + +union SDL_Event; + +namespace ui +{ + 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; + + // For groups that are standalone or not part of any particular group, + // this box group can be used. + static constexpr u32 NO_GROUP = -1; + + // Represents a nonexistent box, i.e. a box with a group of NO_GROUP + // and an item of -1, which no box should use. + static constexpr u64 NO_ID = -1; + + private: + // Indicates that there is no style string for this state combination. + static constexpr u32 NO_STYLE = -1; + + // The element, group, and item are intrinsic to the box's identity, so + // they are set by the constructor and aren't cleared in reset() or + // changed in read(). + Elem &m_elem; + + u32 m_group; + u32 m_item; + + std::vector m_content; + std::string_view m_label; + + Style m_style; + std::array m_style_refs; + + // We cache the font and text content every time the box is restyled. + std::wstring m_text; + gui::IGUIFont *m_font; + + // Cached information about the layout of the box, which is cleared in + // restyle() and recomputed in resize() and relayout(). + SizeF m_min_layout; + SizeF m_min_content; + + RectF m_display_rect; + RectF m_icon_rect; + RectF m_content_rect; + + RectF m_clip_rect; + + public: + Box(Elem &elem, u32 group, u32 item) : + m_elem(elem), + m_group(group), + m_item(item) + { + reset(); + } + + DISABLE_CLASS_COPY(Box) + + Elem &getElem() { return m_elem; } + const Elem &getElem() const { return m_elem; } + + Window &getWindow(); + const Window &getWindow() const; + + u32 getGroup() const { return m_group; } + u32 getItem() const { return m_item; } + u64 getId() const { return ((u64)m_group << 32) | (u64)m_item; } + + const std::vector &getContent() const { return m_content; } + void setContent(std::vector content) { m_content = std::move(content); } + + std::string_view getLabel() const { return m_label; } + void setLabel(std::string_view label) { m_label = label; } + + void reset(); + void read(std::istream &is); + + void restyle(); + void resize(); + void relayout(RectF layout_rect, RectF layout_clip); + + void draw(); + + bool isPointed() const; + bool isContentPointed() const; + + bool processInput(const SDL_Event &event); + bool processFullPress(const SDL_Event &event, void (*on_press)(Elem &)); + + private: + static RectF getLayerSource(const Layer &layer); + static SizeF getLayerSize(const Layer &layer); + + DispF getMiddleEdges(); + + void resizeBox(); + void relayoutBox(RectF layout_rect, RectF layout_clip); + + void resizePlace(); + void relayoutPlace(); + + void drawBox(); + void drawItems(); + + bool isHovered() const; + bool isPressed() const; + + void setPressed(bool pressed); + void setHovered(bool hovered); + }; +} diff --git a/src/ui/clickable_elems.cpp b/src/ui/clickable_elems.cpp new file mode 100644 index 000000000..49a7934ea --- /dev/null +++ b/src/ui/clickable_elems.cpp @@ -0,0 +1,173 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#include "ui/clickable_elems.h" + +#include "debug.h" +#include "log.h" +#include "ui/manager.h" +#include "util/serialize.h" + +namespace ui +{ + void Button::reset() + { + Elem::reset(); + + m_disabled = false; + } + + void Button::read(std::istream &is) + { + auto super = newIs(readStr32(is)); + Elem::read(super); + + u32 set_mask = readU32(is); + + m_disabled = testShift(set_mask); + + if (testShift(set_mask)) + enableEvent(ON_PRESS); + } + + bool Button::processInput(const SDL_Event &event) + { + return getMain().processFullPress(event, UI_CALLBACK(onPress)); + } + + void Button::onPress() + { + if (!m_disabled && testEvent(ON_PRESS)) { + g_manager.sendMessage(createEvent(ON_PRESS).str()); + } + } + + void Toggle::reset() + { + Elem::reset(); + + m_disabled = false; + } + + void Toggle::read(std::istream &is) + { + auto super = newIs(readStr32(is)); + Elem::read(super); + + u32 set_mask = readU32(is); + + m_disabled = testShift(set_mask); + testShiftBool(set_mask, m_selected); + + if (testShift(set_mask)) + enableEvent(ON_PRESS); + if (testShift(set_mask)) + enableEvent(ON_CHANGE); + } + + bool Toggle::processInput(const SDL_Event &event) + { + return getMain().processFullPress(event, UI_CALLBACK(onPress)); + } + + void Toggle::onPress() + { + if (m_disabled) { + return; + } + + m_selected = !m_selected; + + // Send both a press and a change event since both occurred. + if (testEvent(ON_PRESS)) { + g_manager.sendMessage(createEvent(ON_PRESS).str()); + } + if (testEvent(ON_CHANGE)) { + auto os = createEvent(ON_CHANGE); + writeU8(os, m_selected); + + g_manager.sendMessage(os.str()); + } + } + + void Option::reset() + { + Elem::reset(); + + m_disabled = false; + m_family.clear(); + } + + void Option::read(std::istream &is) + { + auto super = newIs(readStr32(is)); + Elem::read(super); + + u32 set_mask = readU32(is); + + m_disabled = testShift(set_mask); + testShiftBool(set_mask, m_selected); + + if (testShift(set_mask)) + m_family = readNullStr(is); + + if (testShift(set_mask)) + enableEvent(ON_PRESS); + if (testShift(set_mask)) + enableEvent(ON_CHANGE); + } + + bool Option::processInput(const SDL_Event &event) + { + return getMain().processFullPress(event, UI_CALLBACK(onPress)); + } + + void Option::onPress() + { + if (m_disabled) { + return; + } + + // Send a press event for this pressed option button. + if (testEvent(ON_PRESS)) { + g_manager.sendMessage(createEvent(ON_PRESS).str()); + } + + // Select this option button unconditionally before deselecting the + // others in the family. + onChange(true); + + // If this option button has no family, then don't do anything else + // since there may be other buttons with the same empty family string. + if (m_family.empty()) { + return; + } + + // If we find any other option buttons in this family, deselect them. + for (Elem *elem : getWindow().getElems()) { + if (elem->getType() != getType()) { + continue; + } + + Option *option = (Option *)elem; + if (option->m_family == m_family && option != this) { + option->onChange(false); + } + } + } + + void Option::onChange(bool selected) + { + bool was_selected = m_selected; + m_selected = selected; + + // If the state of the option button changed, send a change event. + if (was_selected != m_selected && testEvent(ON_CHANGE)) { + auto os = createEvent(ON_CHANGE); + writeU8(os, m_selected); + + g_manager.sendMessage(os.str()); + } + } +} diff --git a/src/ui/clickable_elems.h b/src/ui/clickable_elems.h new file mode 100644 index 000000000..57e1f4ac6 --- /dev/null +++ b/src/ui/clickable_elems.h @@ -0,0 +1,102 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#pragma once + +#include "ui/box.h" +#include "ui/elem.h" +#include "ui/helpers.h" + +#include +#include + +namespace ui +{ + class Button : public Elem + { + private: + // Serialized constants; do not change values of entries. + static constexpr u32 ON_PRESS = 0x00; + + bool m_disabled; + + public: + Button(Window &window, std::string id) : + Elem(window, std::move(id)) + {} + + virtual Type getType() const override { return BUTTON; } + + virtual void reset() override; + virtual void read(std::istream &is) override; + + virtual bool isBoxDisabled(const Box &box) const override { return m_disabled; } + + virtual bool processInput(const SDL_Event &event) override; + + private: + void onPress(); + }; + + class Toggle : public Elem + { + private: + // Serialized constants; do not change values of entries. + static constexpr u32 ON_PRESS = 0x00; + static constexpr u32 ON_CHANGE = 0x01; + + bool m_disabled; + bool m_selected = false; // Persistent + + public: + Toggle(Window &window, std::string id) : + Elem(window, std::move(id)) + {} + + virtual Type getType() const override { return TOGGLE; } + + virtual void reset() override; + virtual void read(std::istream &is) override; + + virtual bool isBoxSelected(const Box &box) const override { return m_selected; } + virtual bool isBoxDisabled(const Box &box) const override { return m_disabled; } + + virtual bool processInput(const SDL_Event &event) override; + + private: + void onPress(); + }; + + class Option : public Elem + { + private: + // Serialized constants; do not change values of entries. + static constexpr u32 ON_PRESS = 0x00; + static constexpr u32 ON_CHANGE = 0x01; + + bool m_disabled; + std::string m_family; + + bool m_selected = false; // Persistent + + public: + Option(Window &window, std::string id) : + Elem(window, std::move(id)) + {} + + virtual Type getType() const override { return OPTION; } + + virtual void reset() override; + virtual void read(std::istream &is) override; + + virtual bool isBoxSelected(const Box &box) const override { return m_selected; } + virtual bool isBoxDisabled(const Box &box) const override { return m_disabled; } + + virtual bool processInput(const SDL_Event &event) override; + + private: + void onPress(); + void onChange(bool selected); + }; +} diff --git a/src/ui/elem.cpp b/src/ui/elem.cpp new file mode 100644 index 000000000..64dea6d5f --- /dev/null +++ b/src/ui/elem.cpp @@ -0,0 +1,152 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#include "ui/elem.h" + +#include "debug.h" +#include "log.h" +#include "ui/manager.h" +#include "ui/window.h" +#include "util/serialize.h" + +// Include every element header for Elem::create() +#include "ui/clickable_elems.h" +#include "ui/static_elems.h" + +#include + +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); + CREATE(BUTTON, Button); + CREATE(TOGGLE, Toggle); + CREATE(OPTION, Option); + 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, Box::NO_GROUP, MAIN_BOX) + {} + + Elem::~Elem() + { + // Make sure we don't leave any dangling pointers in the window. + m_window.clearElem(this); + } + + void Elem::reset() + { + m_order = (size_t)-1; + + m_parent = nullptr; + m_children.clear(); + + m_label = ""; + m_main_box.reset(); + + m_events = 0; + } + + void Elem::read(std::istream &is) + { + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + readChildren(is); + if (testShift(set_mask)) + m_label = readStr16(is); + if (testShift(set_mask)) + m_main_box.read(is); + + std::vector content; + for (Elem *elem : m_children) { + content.push_back(&elem->getMain()); + } + + m_main_box.setContent(std::move(content)); + m_main_box.setLabel(m_label); + } + + bool Elem::isFocused() const + { + return m_window.isFocused() && m_window.getFocused() == this; + } + + void Elem::enableEvent(u32 event) + { + m_events |= (1 << event); + } + + bool Elem::testEvent(u32 event) const + { + return m_events & (1 << event); + } + + std::ostringstream Elem::createEvent(u32 event) const + { + auto os = newOs(); + + writeU8(os, Manager::ELEM_EVENT); + writeU64(os, m_window.getId()); + writeU8(os, event); + writeU8(os, getType()); + writeNullStr(os, m_id); + + return os; + } + + 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 at this + * point. However, cycles including the root are impossible. + */ + 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/ui/elem.h b/src/ui/elem.h new file mode 100644 index 000000000..1dca2635d --- /dev/null +++ b/src/ui/elem.h @@ -0,0 +1,114 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#pragma once + +#include "ui/box.h" +#include "ui/helpers.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include + +union SDL_Event; + +namespace ui +{ + class Window; + +#define UI_CALLBACK(method) \ + [](Elem &elem) { \ + static_cast(elem).method(); \ + } + + class Elem + { + public: + // Serialized enum; do not change values of entries. + enum Type : u8 + { + ELEM = 0x00, + ROOT = 0x01, + BUTTON = 0x02, + TOGGLE = 0x03, + OPTION = 0x04, + }; + + // The main box is always the zeroth item in the Box::NO_GROUP group. + static constexpr u32 MAIN_BOX = 0; + + 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; + + std::string m_label; + + Box m_main_box; + u64 m_hovered_box = Box::NO_ID; // Persistent + u64 m_pressed_box = Box::NO_ID; // Persistent + + u32 m_events; + + public: + static std::unique_ptr create(Type type, Window &window, std::string id); + + Elem(Window &window, std::string id); + + DISABLE_CLASS_COPY(Elem) + + virtual ~Elem(); + + 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() const { return m_order; } + void setOrder(size_t order) { m_order = order; } + + Elem *getParent() { return m_parent; } + const std::vector &getChildren() { return m_children; } + + Box &getMain() { return m_main_box; } + + u64 getHoveredBox() const { return m_hovered_box; } + u64 getPressedBox() const { return m_pressed_box; } + + void setHoveredBox(u64 id) { m_hovered_box = id; } + void setPressedBox(u64 id) { m_pressed_box = id; } + + virtual void reset(); + virtual void read(std::istream &is); + + bool isFocused() const; + + virtual bool isBoxFocused (const Box &box) const { return isFocused(); } + virtual bool isBoxSelected(const Box &box) const { return false; } + virtual bool isBoxHovered (const Box &box) const { return box.getId() == m_hovered_box; } + virtual bool isBoxPressed (const Box &box) const { return box.getId() == m_pressed_box; } + virtual bool isBoxDisabled(const Box &box) const { return false; } + + virtual bool processInput(const SDL_Event &event) { return false; } + + protected: + void enableEvent(u32 event); + bool testEvent(u32 event) const; + + std::ostringstream createEvent(u32 event) const; + + private: + void readChildren(std::istream &is); + }; +} diff --git a/src/ui/helpers.h b/src/ui/helpers.h new file mode 100644 index 000000000..040d24674 --- /dev/null +++ b/src/ui/helpers.h @@ -0,0 +1,516 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#pragma once + +#include "irrlichttypes.h" +#include "util/serialize.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ui +{ + // Define some useful named colors. + const video::SColor BLANK = 0x00000000; + const video::SColor BLACK = 0xFF000000; + const video::SColor WHITE = 0xFFFFFFFF; + + /* UIs deal with tons of 2D positions, sizes, rectangles, and the like, but + * Irrlicht's core::vector2d, core::dimension2d, and core::rect classes are + * inadequate for the job. For instance, vectors use the mathematical + * definition and hence have multiple special forms of multiplication. + * Notably, they don't have component-wise multiplication, which is the + * only one useful for UIs aside from scalar multiplication. Additionally, + * the distinction between a position and a dimension are blurred since + * vectors can perform operations that make no sense for absolute + * positions, e.g. position + position. Dimensions are underpowered, and + * rectangles are clunky to work with for multiple reasons. + * + * So, we create our own classes for use with 2D drawing and UIs. The Pos + * class represents an absolute position, whereas the Size class represents + * a relative displacement, size, or scaling factor. Similarly, the Rect + * class represents an absolute rectangle defined by two Pos fields, + * whereas the Disp class represents a double relative displacement or + * scaling factor defined by two Size fields. + * + * All operations are component-wise, e.g. the multiplication operation for + * two sizes is defined as `a * b == {a.W * b.W, a.H * b.H}`. Other useful + * operations exist, like component-wise minimums and maximums, unions and + * intersections of rectangles, offseting rectangles with displacements in + * multiple ways, and so on. Lastly, functions never mutate the class in + * place, so you can do + * + * doThing(a.intersectWith(b)); + * + * rather than being forced to use the much more clunky + * + * core::recti c = a; + * c.clipAgainst(b); + * doThing(c); + * + * Implicit conversions between these classes and the corresponding + * Irrlicht classes are defined for seamless interoperability with code + * that still uses Irrlicht's types. + */ + template struct Pos; + template struct Size; + template struct Rect; + template struct Disp; + + template + struct Size + { + E W; + E H; + + Size() : W(), H() {} + explicit Size(E n) : W(n), H(n) {} + Size(E w, E h) : W(w), H(h) {} + + template + explicit Size(Pos pos) : W(pos.X), H(pos.Y) {} + template + Size(Size other) : W(other.W), H(other.H) {} + + template + explicit Size(core::vector2d vec) : W(vec.X), H(vec.Y) {} + template + Size(core::dimension2d dim) : W(dim.Width), H(dim.Height) {} + + template + explicit operator core::vector2d() const { return core::vector2d(W, H); } + template + operator core::dimension2d() const { return core::dimension2d(W, H); } + + E area() const { return W * H; } + bool empty() const { return area() == 0; } + + bool operator==(Size other) const { return W == other.W && H == other.H; } + bool operator!=(Size other) const { return !(*this == other); } + + E &operator[](int index) { return index ? H : W; } + const E &operator[](int index) const { return index ? H : W; } + + Size operator+() const { return Size(+W, +H); } + Size operator-() const { return Size(-W, -H); } + + Size operator+(Size other) const { return Size(W + other.W, H + other.H); } + Size operator-(Size other) const { return Size(W - other.W, H - other.H); } + + Size &operator+=(Size other) { *this = *this + other; return *this; } + Size &operator-=(Size other) { *this = *this - other; return *this; } + + Size operator*(Size other) const { return Size(W * other.W, H * other.H); } + Size operator/(Size other) const { return Size(W / other.W, H / other.H); } + + Size &operator*=(Size other) { *this = *this * other; return *this; } + Size &operator/=(Size other) { *this = *this / other; return *this; } + + Size operator*(E scalar) const { return Size(W * scalar, H * scalar); } + Size operator/(E scalar) const { return Size(W / scalar, H / scalar); } + + Size &operator*=(E scalar) { *this = *this * scalar; return *this; } + Size &operator/=(E scalar) { *this = *this / scalar; return *this; } + + Size min(Size other) const + { return Size(std::min(W, other.W), std::min(H, other.H)); } + Size max(Size other) const + { return Size(std::max(W, other.W), std::max(H, other.H)); } + + Size clamp(Size lo, Size hi) const + { return Size(std::clamp(W, lo.W, hi.W), std::clamp(H, lo.H, hi.H)); } + Size clamp(Disp disp) const + { return clamp(disp.TopLeft, disp.BottomRight); } + + Size clip() const { return max(Size()); } + + friend std::ostream &operator<<(std::ostream &os, Size size) + { + os << "(" << size.W << ", " << size.H << ")"; + return os; + } + }; + + using SizeI = Size; + using SizeU = Size; + using SizeF = Size; + + template + struct Pos + { + E X; + E Y; + + Pos() : X(), Y() {} + explicit Pos(E n) : X(n), Y(n) {} + Pos(E x, E y) : X(x), Y(y) {} + + template + Pos(Pos other) : X(other.X), Y(other.Y) {} + template + explicit Pos(Size size) : X(size.W), Y(size.H) {} + + template + Pos(core::vector2d vec) : X(vec.X), Y(vec.Y) {} + template + explicit Pos(core::dimension2d dim) : X(dim.Width), Y(dim.Height) {} + + template + operator core::vector2d() const { return core::vector2d(X, Y); } + template + explicit operator core::dimension2d() const { return core::dimension2d(X, Y); } + + bool operator==(Pos other) const { return X == other.X && Y == other.Y; } + bool operator!=(Pos other) const { return !(*this == other); } + + E &operator[](int index) { return index ? Y : X; } + const E &operator[](int index) const { return index ? Y : X; } + + Pos operator+(Size size) const { return Pos(X + size.W, Y + size.H); } + Pos operator-(Size size) const { return Pos(X - size.W, Y - size.H); } + + Pos &operator+=(Size size) { *this = *this + size; return *this; } + Pos &operator-=(Size size) { *this = *this - size; return *this; } + + Pos operator*(Size size) const { return Pos(X * size.W, Y * size.H); } + Pos operator/(Size size) const { return Pos(X / size.W, Y / size.H); } + + Pos &operator*=(Size size) { *this = *this * size; return *this; } + Pos &operator/=(Size size) { *this = *this / size; return *this; } + + Pos operator*(E scalar) const { return Pos(X * scalar, Y * scalar); } + Pos operator/(E scalar) const { return Pos(X / scalar, Y / scalar); } + + Pos &operator*=(E scalar) { *this = *this * scalar; return *this; } + Pos &operator/=(E scalar) { *this = *this / scalar; return *this; } + + Size operator-(Pos other) const { return Size(X - other.X, Y - other.Y); } + Size operator/(Pos other) const { return Size(X / other.X, Y / other.Y); } + + Pos min(Pos other) const + { return Pos(std::min(X, other.X), std::min(Y, other.Y)); } + Pos max(Pos other) const + { return Pos(std::max(X, other.X), std::max(Y, other.Y)); } + + Pos clamp(Pos lo, Pos hi) const + { return Pos(std::clamp(X, lo.X, hi.X), std::clamp(Y, lo.Y, hi.Y)); } + Pos clamp(Rect rect) const + { return clamp(rect.TopLeft, rect.BottomRight); } + + friend std::ostream &operator<<(std::ostream &os, Pos pos) + { + os << "(" << pos.X << ", " << pos.Y << ")"; + return os; + } + }; + + using PosI = Pos; + using PosU = Pos; + using PosF = Pos; + + template + struct Disp + { + union { + struct { + E L; + E T; + }; + Size TopLeft; + }; + union { + struct { + E R; + E B; + }; + Size BottomRight; + }; + + Disp() : L(), T(), R(), B() {} + explicit Disp(E n) : L(n), T(n), R(n), B(n) {} + Disp(E x, E y) : L(x), T(y), R(x), B(y) {} + Disp(E l, E t, E r, E b) : L(l), T(t), R(r), B(b) {} + + explicit Disp(Size size) : TopLeft(size), BottomRight(size) {} + Disp(Size tl, Size br) : TopLeft(tl), BottomRight(br) {} + + template + explicit Disp(Rect rect) : TopLeft(rect.TopLeft), BottomRight(rect.BottomRight) {} + template + Disp(Disp other) : TopLeft(other.TopLeft), BottomRight(other.BottomRight) {} + + template + explicit Disp(core::rect rect) : + TopLeft(rect.UpperLeftCorner), BottomRight(rect.LowerRightCorner) {} + + template + explicit operator core::rect() const { return core::rect(Rect(*this)); } + + E X() const { return L + R; } + E Y() const { return T + B; } + Size extents() const { return TopLeft + BottomRight; } + + bool operator==(Disp other) const + { return TopLeft == other.TopLeft && BottomRight == other.BottomRight; } + bool operator!=(Disp other) const { return !(*this == other); } + + Disp operator+() const { return Disp(+TopLeft, +BottomRight); } + Disp operator-() const { return Disp(-TopLeft, -BottomRight); } + + Disp operator+(Disp other) const + { return Disp(TopLeft + other.TopLeft, BottomRight + other.BottomRight); } + Disp operator-(Disp other) const + { return Disp(TopLeft - other.TopLeft, BottomRight - other.BottomRight); } + + Disp &operator+=(Disp other) { *this = *this + other; return *this; } + Disp &operator-=(Disp other) { *this = *this - other; return *this; } + + Disp operator*(Disp other) const + { return Disp(TopLeft * other.TopLeft, BottomRight * other.BottomRight); } + Disp operator/(Disp other) const + { return Disp(TopLeft / other.TopLeft, BottomRight / other.BottomRight); } + + Disp &operator*=(Disp other) { *this = *this * other; return *this; } + Disp &operator/=(Disp other) { *this = *this / other; return *this; } + + Disp operator*(E scalar) const + { return Disp(TopLeft * scalar, BottomRight * scalar); } + Disp operator/(E scalar) const + { return Disp(TopLeft / scalar, BottomRight / scalar); } + + Disp &operator*=(E scalar) { *this = *this * scalar; return *this; } + Disp &operator/=(E scalar) { *this = *this / scalar; return *this; } + + Disp clip() const { return Disp(TopLeft.clip(), BottomRight.clip()); } + + friend std::ostream &operator<<(std::ostream &os, Disp disp) + { + os << "(" << disp.L << ", " << disp.T << ", " << disp.R << ", " << disp.B << ")"; + return os; + } + }; + + using DispI = Disp; + using DispU = Disp; + using DispF = Disp; + + template + struct Rect + { + union { + struct { + E L; + E T; + }; + Pos TopLeft; + }; + union { + struct { + E R; + E B; + }; + Pos BottomRight; + }; + + Rect() : L(), T(), R(), B() {} + Rect(E l, E t, E r, E b) : L(l), T(t), R(r), B(b) {} + + explicit Rect(Pos pos) : TopLeft(pos), BottomRight(pos) {} + Rect(Pos tl, Pos br) : TopLeft(tl), BottomRight(br) {} + + explicit Rect(Size size) : TopLeft(), BottomRight(size) {} + Rect(Pos pos, Size size) : TopLeft(pos), BottomRight(pos + size) {} + + template + Rect(Rect other) : TopLeft(other.TopLeft), BottomRight(other.BottomRight) {} + template + explicit Rect(Disp disp) : TopLeft(disp.TopLeft), BottomRight(disp.BottomRight) {} + + template + Rect(core::rect rect) : + TopLeft(rect.UpperLeftCorner), BottomRight(rect.LowerRightCorner) {} + + template + operator core::rect() const { return core::rect(TopLeft, BottomRight); } + + E W() const { return R - L; } + E H() const { return B - T; } + Size size() const { return BottomRight - TopLeft; } + + E area() const { return size().area(); } + bool empty() const { return size().empty(); } + + bool operator==(Rect other) const + { return TopLeft == other.TopLeft && BottomRight == other.BottomRight; } + bool operator!=(Rect other) const { return !(*this == other); } + + Rect operator+(Disp disp) const + { return Rect(TopLeft + disp.TopLeft, BottomRight + disp.BottomRight); } + Rect operator-(Disp disp) const + { return Rect(TopLeft - disp.TopLeft, BottomRight - disp.BottomRight); } + + Rect &operator+=(Disp disp) { *this = *this + disp; return *this; } + Rect &operator-=(Disp disp) { *this = *this - disp; return *this; } + + Rect operator*(Disp disp) const + { return Rect(TopLeft * disp.TopLeft, BottomRight * disp.BottomRight); } + Rect operator/(Disp disp) const + { return Rect(TopLeft / disp.TopLeft, BottomRight / disp.BottomRight); } + + Rect &operator*=(Disp disp) { *this = *this * disp; return *this; } + Rect &operator/=(Disp disp) { *this = *this / disp; return *this; } + + Rect operator*(E scalar) const + { return Rect(TopLeft * scalar, BottomRight * scalar); } + Rect operator/(E scalar) const + { return Rect(TopLeft / scalar, BottomRight / scalar); } + + Rect &operator*=(E scalar) { *this = *this * scalar; return *this; } + Rect &operator/=(E scalar) { *this = *this / scalar; return *this; } + + Disp operator-(Rect other) const + { return Disp(TopLeft - other.TopLeft, BottomRight - other.BottomRight); } + Disp operator/(Rect other) const + { return Disp(TopLeft / other.TopLeft, BottomRight / other.BottomRight); } + + Rect insetBy(Disp disp) const + { return Rect(TopLeft + disp.TopLeft, BottomRight - disp.BottomRight); } + Rect outsetBy(Disp disp) const + { return Rect(TopLeft - disp.TopLeft, BottomRight + disp.BottomRight); } + + Rect unionWith(Rect other) const + { return Rect(TopLeft.min(other.TopLeft), BottomRight.max(other.BottomRight)); } + Rect intersectWith(Rect other) const + { return Rect(TopLeft.max(other.TopLeft), BottomRight.min(other.BottomRight)); } + + Rect clip() const { return Rect(TopLeft, size().clip()); } + + bool contains(Pos pos) const + { return pos.X >= L && pos.Y >= T && pos.X < R && pos.Y < B; } + + friend std::ostream &operator<<(std::ostream &os, Rect rect) + { + os << "(" << rect.L << ", " << rect.T << ", " << rect.R << ", " << rect.B << ")"; + return os; + } + }; + + using RectI = Rect; + using RectU = Rect; + using RectF = Rect; + + // Define a few functions that are particularly useful for UI serialization + // and deserialization. The testShift() function shifts out and returns the + // lower bit of an integer containing flags, which is particularly useful + // for testing whether an optional serialized field is present or not. + inline bool testShift(u32 &mask) + { + bool test = mask & 1; + mask >>= 1; + return test; + } + + // Booleans are often stored directly in the flags value. However, we want + // the bit position of each field to stay constant, so the mask needs to be + // shifted regardless of whether the boolean is set. + inline void testShiftBool(u32 &mask, bool &flag) + { + if (testShift(mask)) { + flag = testShift(mask); + } else { + testShift(mask); + } + } + + // Convenience functions for creating new binary streams. + inline std::istringstream newIs(const std::string &str) + { + return std::istringstream(str, std::ios_base::binary); + } + + inline std::ostringstream newOs() + { + return std::ostringstream(std::ios_base::binary); + } + + // The UI purposefully avoids dealing with SerializationError, so it always + // uses string functions that truncate gracefully. Hence, we make + // convenience wrappers around the string functions in "serialize.h". + inline std::string readStr16(std::istream &is) { return deSerializeString16(is, true); } + inline std::string readStr32(std::istream &is) { return deSerializeString32(is, true); } + + inline void writeStr16(std::ostream &os, std::string_view str) + { os << serializeString16(str, true); } + inline void writeStr32(std::ostream &os, std::string_view str) + { os << serializeString32(str, true); } + + // The UI also uses null-terminated strings for certain fields as well, so + // define functions to work with them. + inline std::string readNullStr(std::istream &is) + { + std::string str; + std::getline(is, str, '\0'); + return str; + } + + inline void writeNullStr(std::ostream &os, std::string_view str) + { + os << std::string_view(str.data(), std::min(str.find('\0'), str.size())) << '\0'; + } + + // Define serialization and deserialization functions that work with the + // positioning types above. Brace initializer lists are used for the + // constructors because they guarantee left-to-right argument evaluation. + inline PosI readPosI(std::istream &is) { return PosI{readS32(is), readS32(is)}; } + inline PosU readPosU(std::istream &is) { return PosU{readU32(is), readU32(is)}; } + inline PosF readPosF(std::istream &is) { return PosF{readF32(is), readF32(is)}; } + + inline void writePosI(std::ostream &os, PosI pos) + { writeS32(os, pos.X); writeS32(os, pos.Y); } + inline void writePosU(std::ostream &os, PosU pos) + { writeU32(os, pos.X); writeU32(os, pos.Y); } + inline void writePosF(std::ostream &os, PosF pos) + { writeF32(os, pos.X); writeF32(os, pos.Y); } + + inline SizeI readSizeI(std::istream &is) { return SizeI{readS32(is), readS32(is)}; } + inline SizeU readSizeU(std::istream &is) { return SizeU{readU32(is), readU32(is)}; } + inline SizeF readSizeF(std::istream &is) { return SizeF{readF32(is), readF32(is)}; } + + inline void writeSizeI(std::ostream &os, SizeI size) + { writeS32(os, size.W); writeS32(os, size.H); } + inline void writeSizeU(std::ostream &os, SizeU size) + { writeU32(os, size.W); writeU32(os, size.H); } + inline void writeSizeF(std::ostream &os, SizeF size) + { writeF32(os, size.W); writeF32(os, size.H); } + + inline RectI readRectI(std::istream &is) { return RectI{readPosI(is), readPosI(is)}; } + inline RectU readRectU(std::istream &is) { return RectU{readPosU(is), readPosU(is)}; } + inline RectF readRectF(std::istream &is) { return RectF{readPosF(is), readPosF(is)}; } + + inline void writeRectI(std::ostream &os, RectI rect) + { writePosI(os, rect.TopLeft); writePosI(os, rect.BottomRight); } + inline void writeRectU(std::ostream &os, RectU rect) + { writePosU(os, rect.TopLeft); writePosU(os, rect.BottomRight); } + inline void writeRectF(std::ostream &os, RectF rect) + { writePosF(os, rect.TopLeft); writePosF(os, rect.BottomRight); } + + inline DispI readDispI(std::istream &is) { return DispI{readSizeI(is), readSizeI(is)}; } + inline DispU readDispU(std::istream &is) { return DispU{readSizeU(is), readSizeU(is)}; } + inline DispF readDispF(std::istream &is) { return DispF{readSizeF(is), readSizeF(is)}; } + + inline void writeDispI(std::ostream &os, DispI disp) + { writeSizeI(os, disp.TopLeft); writeSizeI(os, disp.BottomRight); } + inline void writeDispU(std::ostream &os, DispU disp) + { writeSizeU(os, disp.TopLeft); writeSizeU(os, disp.BottomRight); } + inline void writeDispF(std::ostream &os, DispF disp) + { writeSizeF(os, disp.TopLeft); writeSizeF(os, disp.BottomRight); } +} diff --git a/src/ui/manager.cpp b/src/ui/manager.cpp new file mode 100644 index 000000000..e557fae2b --- /dev/null +++ b/src/ui/manager.cpp @@ -0,0 +1,172 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#include "ui/manager.h" + +#include "debug.h" +#include "log.h" +#include "settings.h" +#include "client/client.h" +#include "client/renderingengine.h" +#include "client/texturesource.h" +#include "client/tile.h" +#include "gui/mainmenumanager.h" +#include "util/serialize.h" + +#include + +namespace ui +{ + SDL_Event createUiEvent(UiEvent type, void *data1, void *data2) + { + SDL_Event event; + + event.user.type = type + SDL_USEREVENT; + event.user.code = 0; + event.user.data1 = data1; + event.user.data2 = data2; + + return event; + } + + video::ITexture *Manager::getTexture(const std::string &name) const + { + return m_client->tsrc()->getTexture(name); + } + + float Manager::getScale(WindowType type) const + { + if (type == WindowType::GUI || type == WindowType::CHAT) { + return m_gui_scale; + } + return m_hud_scale; + } + + void Manager::reset() + { + m_client = nullptr; + + m_windows.clear(); + m_gui_windows.clear(); + } + + void Manager::removeWindow(u64 id) + { + auto it = m_windows.find(id); + if (it == m_windows.end()) { + errorstream << "Window " << id << " is already closed" << std::endl; + return; + } + + m_windows.erase(it); + m_gui_windows.erase(id); + } + + 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()) { + errorstream << "Window " << id << " is already open" << std::endl; + break; + } + + it = m_windows.emplace(id, id).first; + if (!it->second.read(is, true)) { + errorstream << "Fatal error when opening window " << id << + "; closing window" << std::endl; + removeWindow(id); + break; + } + + if (it->second.getType() == WindowType::GUI) { + m_gui_windows.emplace(id, &it->second); + } + break; + } + + case UPDATE_WINDOW: { + auto it = m_windows.find(id); + if (it == m_windows.end()) { + errorstream << "Window " << id << " does not exist" << std::endl; + } + + if (!it->second.read(is, false)) { + errorstream << "Fatal error when updating window " << id << + "; closing window" << std::endl; + removeWindow(id); + break; + } + break; + } + + case CLOSE_WINDOW: + removeWindow(id); + break; + + default: + errorstream << "Invalid manager action: " << action << std::endl; + break; + } + } + + void Manager::sendMessage(const std::string &data) + { + m_client->sendUiMessage(data.c_str(), data.size()); + } + + void Manager::preDraw() + { + float base_scale = RenderingEngine::getDisplayDensity(); + m_gui_scale = base_scale * g_settings->getFloat("gui_scaling"); + m_hud_scale = base_scale * g_settings->getFloat("hud_scaling"); + } + + void Manager::drawType(WindowType type) + { + for (auto &it : m_windows) { + if (it.second.getType() == type) { + it.second.drawAll(); + } + } + } + + Window *Manager::getFocused() + { + if (m_gui_windows.empty()) { + return nullptr; + } + return m_gui_windows.rbegin()->second; + } + + bool Manager::isFocused() const + { + return g_menumgr.menuCount() == 0 && !m_gui_windows.empty(); + } + + bool Manager::processInput(const SDL_Event &event) + { + Window *focused = getFocused(); + if (focused != nullptr) { + return focused->processInput(event); + } + + return false; + } + + Manager g_manager; +} diff --git a/src/ui/manager.h b/src/ui/manager.h new file mode 100644 index 000000000..4c9d1840c --- /dev/null +++ b/src/ui/manager.h @@ -0,0 +1,125 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#pragma once + +#include "ui/helpers.h" +#include "ui/window.h" +#include "util/basic_macros.h" + +#include + +#include +#include +#include + +class Client; + +union SDL_Event; + +namespace ui +{ + /* Custom UI-specific event types for events of type SDL_USEREVENT. Create + * the event structure with createUiEvent(). + * + * Some events should always return false to give parent elements and other + * boxes a chance to see the event. Other events return true to indicate + * that the element may become focused or hovered. + */ + enum UiEvent + { + UI_FOCUS_REQUEST, // Return true to accept request. + UI_FOCUS_CHANGED, // Never return true. + UI_FOCUS_SUBVERTED, // Not sent to parent elements. Never return true. + + UI_HOVER_REQUEST, // Return true to accept request. + UI_HOVER_CHANGED, + }; + +#define UI_USER(event) (UI_##event + SDL_USEREVENT) + + SDL_Event createUiEvent(UiEvent type, void *data1 = nullptr, void *data2 = nullptr); + + class Manager + { + public: + // Serialized enum; do not change values of entries. + enum ReceiveAction : u8 + { + OPEN_WINDOW = 0x00, + REOPEN_WINDOW = 0x01, + UPDATE_WINDOW = 0x02, + CLOSE_WINDOW = 0x03, + }; + + // Serialized enum; do not change values of entries. + enum SendAction : u8 + { + WINDOW_EVENT = 0x00, + ELEM_EVENT = 0x01, + }; + + private: + Client *m_client; + + float m_gui_scale = 0.0f; + float m_hud_scale = 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; + + // Keep track of which GUI windows are currently open. We also use a + // map so we can easily find the topmost window. + std::map m_gui_windows; + + public: + Manager() + { + reset(); + } + + DISABLE_CLASS_COPY(Manager) + + Client *getClient() const { return m_client; } + void setClient(Client *client) { m_client = client; } + + video::ITexture *getTexture(const std::string &name) const; + + float getScale(WindowType type) const; + + void reset(); + void removeWindow(u64 id); + + void receiveMessage(const std::string &data); + void sendMessage(const std::string &data); + + void preDraw(); + void drawType(WindowType type); + + Window *getFocused(); + + bool isFocused() const; + bool processInput(const SDL_Event &event); + }; + + 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, core::recti()) + {} + + virtual void draw() override + { + g_manager.drawType(ui::WindowType::GUI); + gui::IGUIElement::draw(); + } + }; +} diff --git a/src/ui/static_elems.cpp b/src/ui/static_elems.cpp new file mode 100644 index 000000000..a3894d0c0 --- /dev/null +++ b/src/ui/static_elems.cpp @@ -0,0 +1,38 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#include "ui/static_elems.h" + +#include "debug.h" +#include "log.h" +#include "ui/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); + + m_backdrop_box.setContent({&getMain()}); + } + + bool Root::isBoxFocused(const Box &box) const + { + return box.getItem() == BACKDROP_BOX ? getWindow().isFocused() : isFocused(); + } +} diff --git a/src/ui/static_elems.h b/src/ui/static_elems.h new file mode 100644 index 000000000..ff2bb61b1 --- /dev/null +++ b/src/ui/static_elems.h @@ -0,0 +1,38 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#pragma once + +#include "ui/box.h" +#include "ui/elem.h" +#include "ui/helpers.h" + +#include +#include + +namespace ui +{ + class Root : public Elem + { + private: + Box m_backdrop_box; + + static constexpr u32 BACKDROP_BOX = 1; + + public: + Root(Window &window, std::string id) : + Elem(window, std::move(id)), + m_backdrop_box(*this, Box::NO_GROUP, BACKDROP_BOX) + {} + + virtual Type getType() const override { return ROOT; } + + Box &getBackdrop() { return m_backdrop_box; } + + virtual void reset() override; + virtual void read(std::istream &is) override; + + virtual bool isBoxFocused(const Box &box) const override; + }; +} diff --git a/src/ui/style.cpp b/src/ui/style.cpp new file mode 100644 index 000000000..6b1970682 --- /dev/null +++ b/src/ui/style.cpp @@ -0,0 +1,242 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#include "ui/style.h" + +#include "debug.h" +#include "log.h" +#include "ui/manager.h" +#include "util/serialize.h" + +namespace ui +{ + static LayoutType toLayoutType(u8 type) + { + if (type > (u8)LayoutType::MAX) { + return LayoutType::PLACE; + } + return (LayoutType)type; + } + + static DirFlags toDirFlags(u8 dir) + { + if (dir > (u8)DirFlags::MAX) { + return DirFlags::NONE; + } + return (DirFlags)dir; + } + + static DisplayMode toDisplayMode(u8 mode) + { + if (mode > (u8)DisplayMode::MAX) { + return DisplayMode::VISIBLE; + } + return (DisplayMode)mode; + } + + static IconPlace toIconPlace(u8 place) + { + if (place > (u8)IconPlace::MAX) { + return IconPlace::CENTER; + } + return (IconPlace)place; + } + + static Align toAlign(u8 align) + { + if (align > (u8)Align::MAX) { + return Align::CENTER; + } + return (Align)align; + } + + void Layout::reset() + { + type = LayoutType::PLACE; + clip = DirFlags::NONE; + + scale = 0.0f; + } + + void Layout::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + type = toLayoutType(readU8(is)); + if (testShift(set_mask)) + clip = toDirFlags(readU8(is)); + + if (testShift(set_mask)) + scale = std::max(readF32(is), 0.0f); + } + + void Sizing::reset() + { + size = SizeF(0.0f, 0.0f); + span = SizeF(1.0f, 1.0f); + + pos = PosF(0.0f, 0.0f); + anchor = PosF(0.0f, 0.0f); + + margin = DispF(0.0f, 0.0f, 0.0f, 0.0f); + padding = DispF(0.0f, 0.0f, 0.0f, 0.0f); + } + + void Sizing::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + size = readSizeF(is).clip(); + if (testShift(set_mask)) + span = readSizeF(is).clip(); + + if (testShift(set_mask)) + pos = readPosF(is); + if (testShift(set_mask)) + anchor = readPosF(is); + + if (testShift(set_mask)) + margin = readDispF(is); + if (testShift(set_mask)) + padding = readDispF(is); + } + + void Layer::reset() + { + image = nullptr; + fill = BLANK; + tint = WHITE; + + scale = 1.0f; + source = RectF(0.0f, 0.0f, 1.0f, 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)) + scale = std::max(readF32(is), 0.0f); + if (testShift(set_mask)) + source = readRectF(is); + + if (testShift(set_mask)) + num_frames = std::max(readU32(is), 1U); + if (testShift(set_mask)) + frame_time = std::max(readU32(is), 1U); + } + + void Text::reset() + { + prepend = ""; + append = ""; + + color = WHITE; + mark = BLANK; + size = 16; + + mono = false; + italic = false; + bold = false; + + align = Align::CENTER; + valign = Align::CENTER; + } + + void Text::read(std::istream &full_is) + { + auto is = newIs(readStr16(full_is)); + u32 set_mask = readU32(is); + + if (testShift(set_mask)) + prepend = readStr16(is); + if (testShift(set_mask)) + append = readStr16(is); + + if (testShift(set_mask)) + color = readARGB8(is); + if (testShift(set_mask)) + mark = readARGB8(is); + if (testShift(set_mask)) + size = std::clamp(readU32(is), 1U, 999U); + + testShiftBool(set_mask, mono); + testShiftBool(set_mask, italic); + testShiftBool(set_mask, bold); + + if (testShift(set_mask)) + align = toAlign(readU8(is)); + if (testShift(set_mask)) + valign = toAlign(readU8(is)); + } + + void Style::reset() + { + layout.reset(); + sizing.reset(); + + display = DisplayMode::VISIBLE; + + box.reset(); + icon.reset(); + + box_middle = DispF(0.0f, 0.0f, 0.0f, 0.0f); + box_tile = DirFlags::NONE; + + icon_place = IconPlace::CENTER; + icon_gutter = 0.0f; + icon_overlap = false; + + text.reset(); + } + + 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)) + layout.read(is); + if (testShift(set_mask)) + sizing.read(is); + + if (testShift(set_mask)) + display = toDisplayMode(readU8(is)); + + if (testShift(set_mask)) + box.read(is); + if (testShift(set_mask)) + icon.read(is); + + if (testShift(set_mask)) + box_middle = readDispF(is).clip(); + if (testShift(set_mask)) + box_tile = toDirFlags(readU8(is)); + + if (testShift(set_mask)) + icon_place = toIconPlace(readU8(is)); + if (testShift(set_mask)) + icon_gutter = readF32(is); + testShiftBool(set_mask, icon_overlap); + + if (testShift(set_mask)) + text.read(is); + } +} diff --git a/src/ui/style.h b/src/ui/style.h new file mode 100644 index 000000000..9d2b330dd --- /dev/null +++ b/src/ui/style.h @@ -0,0 +1,160 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2024 v-rob, Vincent Robinson + +#pragma once + +#include "ui/helpers.h" + +#include +#include + +namespace ui +{ + // Serialized enum; do not change order of entries. + enum class LayoutType : u8 + { + PLACE, + + MAX = PLACE, + }; + + // Serialized enum; do not change order of entries. + enum class DirFlags : u8 + { + NONE, + X, + Y, + BOTH, + + MAX = BOTH, + }; + + // Serialized enum; do not change order of entries. + enum class DisplayMode : u8 + { + VISIBLE, + OVERFLOW, + HIDDEN, + CLIPPED, + + MAX = CLIPPED, + }; + + // Serialized enum; do not change order of entries. + enum class IconPlace : u8 + { + CENTER, + LEFT, + TOP, + RIGHT, + BOTTOM, + + MAX = BOTTOM, + }; + + // Serialized enum; do not change order of entries. + enum class Align : u8 + { + START, + CENTER, + END, + + MAX = END, + }; + + struct Layout + { + LayoutType type; + DirFlags clip; + + float scale; + + Layout() { reset(); } + + void reset(); + void read(std::istream &is); + }; + + struct Sizing + { + SizeF size; + SizeF span; + + PosF pos; + PosF anchor; + + DispF margin; + DispF padding; + + Sizing() { reset(); } + + void reset(); + void read(std::istream &is); + }; + + struct Layer + { + video::ITexture *image; + video::SColor fill; + video::SColor tint; + + float scale; + RectF source; + + u32 num_frames; + u32 frame_time; + + Layer() { reset(); } + + void reset(); + void read(std::istream &is); + }; + + struct Text + { + std::string prepend; + std::string append; + + video::SColor color; + video::SColor mark; + u32 size; + + bool mono; + bool italic; + bool bold; + + Align align; + Align valign; + + Text() { reset(); } + + void reset(); + void read(std::istream &is); + }; + + struct Style + { + Layout layout; + Sizing sizing; + + DisplayMode display; + + Layer box; + Layer icon; + + DispF box_middle; + DirFlags box_tile; + + IconPlace icon_place; + float icon_gutter; + bool icon_overlap; + + Text text; + + Style() { reset(); } + + void reset(); + void read(std::istream &is); + }; +} diff --git a/src/ui/window.cpp b/src/ui/window.cpp new file mode 100644 index 000000000..44613dbfa --- /dev/null +++ b/src/ui/window.cpp @@ -0,0 +1,812 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#include "ui/window.h" + +#include "debug.h" +#include "log.h" +#include "settings.h" +#include "client/client.h" +#include "client/renderingengine.h" +#include "client/tile.h" +#include "ui/box.h" +#include "ui/manager.h" +#include "ui/static_elems.h" +#include "util/serialize.h" +#include "util/string.h" + +#include + +namespace ui +{ + SizeI getTextureSize(video::ITexture *texture) + { + if (texture != nullptr) { + return SizeI(texture->getOriginalSize()); + } + return SizeI(0, 0); + } + + WindowType toWindowType(u8 type) + { + if (type > (u8)WindowType::MAX) { + 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; + } + + Elem *Window::getNextElem(Elem *elem, bool reverse) + { + size_t next = elem->getOrder(); + size_t last = m_ordered_elems.size() - 1; + + if (!reverse) { + next = (next == last) ? 0 : next + 1; + } else { + next = (next == 0) ? last : next - 1; + } + + return m_ordered_elems[next]; + } + + void Window::clearElem(Elem *elem) + { + if (m_focused_elem == elem) { + m_focused_elem = nullptr; + } + if (m_hovered_elem == elem) { + m_hovered_elem = 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(); + + m_focused_elem = nullptr; + m_hovered_elem = nullptr; + + m_allow_close = true; + m_events = 0; + } + + bool Window::read(std::istream &is, bool opening) + { + // Read in all the fundamental properties that must be unconditionally + // provided for the window. + std::unordered_map elem_contents; + readElems(is, elem_contents); + + if (!readRootElem(is)) { + return false; + } + + readStyles(is); + + if (opening) { + m_type = toWindowType(readU8(is)); + } + + // After the unconditional properties, read the conditional ones. + u32 set_mask = readU32(is); + + bool set_focus = false; + Elem *new_focused = nullptr; + + if (testShift(set_mask)) { + new_focused = getElem(readNullStr(is), false); + set_focus = true; + } + if (opening) { + m_allow_close = testShift(set_mask); + } else { + testShift(set_mask); + } + + if (testShift(set_mask)) + enableEvent(ON_SUBMIT); + if (testShift(set_mask)) + enableEvent(ON_FOCUS_CHANGE); + + return updateElems(elem_contents, set_focus, new_focused); + } + + float Window::getScale() const + { + return g_manager.getScale(m_type); + } + + SizeF Window::getScreenSize() const + { + SizeF size = RenderingEngine::get_video_driver()->getCurrentRenderTargetSize(); + return size / getScale(); + } + + PosF Window::getPointerPos() const + { + int x, y; + SDL_GetMouseState(&x, &y); + + return PosF(x, y) / getScale(); + } + + SizeF Window::getTextSize(gui::IGUIFont *font, std::wstring_view text) + { + // If we have an empty string, we want it to take up no space. IGUIFont + // measures the dimensions of an empty string as having the normal line + // height rather than no space. + if (text.empty()) { + return SizeF(); + } + + // IGUIFont measures the height of text with newlines incorrectly, so + // we have to measure each line in the string individually. + SizeF text_size = SizeF(); + size_t start = 0; + + while (start <= text.size()) { + // Get the line spanning from the start of this line to the next + // newline, or the end of the string if there are no more newlines. + size_t end = std::min(text.find(L'\n', start), text.size()); + std::wstring line(text.substr(start, end - start)); + + // Get the dimensions of the line. Since fonts are already scaled, + // we have to reverse the scaling factor to get the right size. + SizeF line_size = SizeF(font->getDimension( + unescape_enriched(line).c_str())) / getScale(); + + text_size.W = std::max(text_size.W, line_size.W); + text_size.H += line_size.H; + + // Move the start of the current line to after the end of this one. + start = end + 1; + } + + return text_size; + } + + void Window::drawRect(RectF dst, RectF clip, video::SColor color) + { + if (dst.intersectWith(clip).empty() || color.getAlpha() == 0) { + return; + } + + core::recti scaled_clip = clip * getScale(); + + RenderingEngine::get_video_driver()->draw2DRectangle( + color, dst * getScale(), &scaled_clip); + } + + void Window::drawTexture(RectF dst, RectF clip, video::ITexture *texture, + RectF src, video::SColor tint) + { + if (dst.intersectWith(clip).empty() || + texture == nullptr || tint.getAlpha() == 0) { + return; + } + + core::recti scaled_clip = clip * getScale(); + video::SColor colors[] = {tint, tint, tint, tint}; + + RenderingEngine::get_video_driver()->draw2DImage(texture, dst * getScale(), + src * DispF(getTextureSize(texture)), &scaled_clip, colors, true); + } + + void Window::drawText(RectF dst, RectF clip, gui::IGUIFont *font, + std::wstring_view text, video::SColor color, video::SColor mark, + Align align, Align valign) + { + if (dst.intersectWith(clip).empty() || font == nullptr || text.empty()) { + return; + } + + // We count the number of lines in the text to find the total height. + size_t num_lines = std::count(text.begin(), text.end(), L'\n') + 1; + + // Get the height of a single line, and use this with the vertical + // alignment to find the vertical position of the first line. + float height = font->getDimension(L"").Height / getScale(); + float top; + + switch (valign) { + case Align::START: + top = dst.T; + break; + case Align::CENTER: + top = (dst.T + dst.B - (height * num_lines)) / 2.0f; + break; + case Align::END: + top = dst.B - (height * num_lines); + break; + } + + core::recti scaled_clip = clip * getScale(); + + // Like getTextSize(), we loop over each line in the string. + size_t start = 0; + + while (start <= text.size()) { + size_t end = std::min(text.find(L'\n', start), text.size()); + std::wstring line(text.substr(start, end - start)); + + // Get the width of this line of text. Just like the height, we use + // the alignment to find the horizontal position for this line. + float width = font->getDimension( + unescape_enriched(line).c_str()).Width / getScale(); + float left; + + switch (align) { + case Align::START: + left = dst.L; + break; + case Align::CENTER: + left = (dst.L + dst.R - width) / 2.0f; + break; + case Align::END: + left = dst.R - width; + break; + } + + // This gives us the destination rect for this line of the text, + // which we scale appropriately. + RectF line_rect = RectF(PosF(left, top), SizeF(width, height)) * getScale(); + + // If we have a highlight color for the text, draw the highlight + // before we draw the line. + if (mark.getAlpha() != 0) { + RenderingEngine::get_video_driver()->draw2DRectangle( + mark, line_rect, &scaled_clip); + } + + // Then draw the text itself using the provided font. + font->draw(line.c_str(), line_rect, color, false, false, &scaled_clip); + + // Finally, advance to the next line. + top += height; + start = end + 1; + } + } + + void Window::drawAll() + { + Box &backdrop = m_root_elem->getBackdrop(); + + // Since the elements, screen size, pixel size, or style properties + // might have changed since the last frame, we need to recompute stuff + // before drawing: restyle all the boxes, recompute the base sizes from + // the leaves to the root, and then layout each element in the element + // tree from the root to the leaves. + backdrop.restyle(); + backdrop.resize(); + + RectF layout_rect(getScreenSize()); + backdrop.relayout(layout_rect, layout_rect); + + // Find the current hovered element, which will be nothing if we're not + // focused. This may have changed between draw calls due to window + // resizes or element layouting. + hoverPointedElem(); + + // If this window isn't focused, tell the currently focused element. + if (!isFocused()) { + SDL_Event notice = createUiEvent(UI_FOCUS_SUBVERTED); + sendTreeInput(m_focused_elem, notice, true); + } + + // Draw all of the newly layouted and updated elements. + backdrop.draw(); + } + + bool Window::isFocused() const + { + return g_manager.isFocused() && g_manager.getFocused() == this; + } + + bool Window::processInput(const SDL_Event &event) + { + switch (event.type) { + case SDL_KEYDOWN: + case SDL_KEYUP: { + // Send the keypresses off to the focused element for processing. + // The hovered element never gets keypresses. + if (sendFocusedInput(event) != nullptr) { + return true; + } + + if (event.type == SDL_KEYDOWN) { + u16 mod = event.key.keysym.mod; + + switch (event.key.keysym.sym) { + case SDLK_ESCAPE: + // If we got an Escape keypress, close the window. + destroyWindow(); + return true; + + case SDLK_RETURN: + case SDLK_RETURN2: + case SDLK_KP_ENTER: + // If the Enter key was pressed but not handled by any + // elements, send a submit event to the server. + if (testEvent(ON_SUBMIT)) { + g_manager.sendMessage(createEvent(ON_SUBMIT).str()); + } + return true; + + case SDLK_TAB: + case SDLK_KP_TAB: + // If we got a Tab key press, but not a Ctrl + Tab (which + // is reserved for use by elements), focus the next + // element, or the previous element if Shift is pressed. + if (!(mod & KMOD_CTRL)) { + focusNextElem(mod & KMOD_SHIFT); + return true; + } + break; + + default: + break; + } + } + return false; + } + + case SDL_MOUSEMOTION: + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + case SDL_MOUSEWHEEL: { + // Make sure that we have an updated hovered element so that the + // hovered element is the one that receives the mouse motion event. + if (event.type == SDL_MOUSEMOTION) { + hoverPointedElem(); + } + + // If we just clicked with the left mouse button, see if there's + // any element at that position to focus. + if (event.type == SDL_MOUSEBUTTONDOWN && + event.button.button == SDL_BUTTON_LEFT) { + if (isPointerOutside()) { + changeFocusedElem(nullptr, true); + } else { + focusPointedElem(); + } + } + + // First, give the focused element a chance to see the mouse event, + // so it can e.g. unpress a button if a mouse button was released. + if (sendFocusedInput(event) != nullptr) { + return true; + } + + // Then, send the mouse input to the hovered element. + if (sendPointedInput(event) != nullptr) { + return true; + } + return false; + } + + default: + return false; + } + } + + void Window::enableEvent(u32 event) + { + m_events |= (1 << event); + } + + bool Window::testEvent(u32 event) const + { + return m_events & (1 << event); + } + + std::ostringstream Window::createEvent(u32 event) const + { + auto os = newOs(); + + writeU8(os, Manager::WINDOW_EVENT); + writeU64(os, m_id); + writeU8(os, event); + + return os; + } + + void Window::destroyWindow() + { + if (m_allow_close) { + // Always send the close event so the server can update its + // internal window tables, even if there's no on_close() handler. + g_manager.sendMessage(createEvent(ON_CLOSE).str()); + + // This causes the window object to be destroyed. Do not run any + // code after this! + g_manager.removeWindow(m_id); + } + } + + Elem *Window::sendTreeInput(Elem *elem, const SDL_Event &event, bool direct) + { + // Give the event to the element and all its parents for processing. + while (elem != nullptr) { + bool handled = elem->processInput(event); + + if (handled) { + // If we handled the event, return the element that handled it. + return elem; + } else if (direct) { + // If this event is only intended directly for this element and + // it didn't handle the event, then we're done. + return nullptr; + } + + // Otherwise, give the parent a chance to handle it. + elem = elem->getParent(); + } + return nullptr; + } + + Elem *Window::sendPointedInput(const SDL_Event &event) + { + // We want to get the topmost hovered element, so we have to iterate in + // reverse draw order and check each element the mouse is inside. + for (size_t i = m_ordered_elems.size(); i > 0; i--) { + Elem *elem = m_ordered_elems[i - 1]; + if (elem->getMain().isContentPointed() && elem->processInput(event)) { + return elem; + } + } + + return nullptr; + } + + Elem *Window::sendFocusedInput(const SDL_Event &event) + { + if (m_focused_elem == nullptr) { + return nullptr; + } + + // Send the event to the focused element and its parents. + Elem *handled = sendTreeInput(m_focused_elem, event, false); + + // If one of the focused element's parents handled the event, let the + // focused element know that focus was subverted. + if (handled != nullptr && handled != m_focused_elem) { + SDL_Event notice = createUiEvent(UI_FOCUS_SUBVERTED); + sendTreeInput(m_focused_elem, notice, true); + } + + return handled; + } + + void Window::changeFocusedElem(Elem *new_focused, bool send_event) + { + // If the same element is being focused, do nothing. + if (new_focused == m_focused_elem) { + return; + } + + Elem *old_focused = m_focused_elem; + m_focused_elem = new_focused; + + // Let the old and new focused elements know that things have + // changed, and their parent elements too. + SDL_Event notice = createUiEvent(UI_FOCUS_CHANGED, old_focused, new_focused); + + sendTreeInput(old_focused, notice, false); + sendTreeInput(new_focused, notice, false); + + // If the server wants to know when focus changes, send it an event. + if (send_event && testEvent(ON_FOCUS_CHANGE)) { + auto os = createEvent(ON_FOCUS_CHANGE); + + // If either the old or the new element was unfocused, send an + // empty string. Otherwise, send the ID of the element. + writeNullStr(os, old_focused == nullptr ? "" : old_focused->getId()); + writeNullStr(os, new_focused == nullptr ? "" : new_focused->getId()); + + g_manager.sendMessage(os.str()); + } + } + + bool Window::requestFocusedElem(Elem *new_focused, bool send_event) + { + // If this element is already focused, we don't need to do anything. + if (new_focused == m_focused_elem) { + return m_focused_elem; + } + + SDL_Event notice = createUiEvent(UI_FOCUS_REQUEST); + + // Ask the new element if it can take user focus. If it can, make it + // the focused element. + if (sendTreeInput(new_focused, notice, true) == new_focused) { + changeFocusedElem(new_focused, send_event); + return true; + } + return false; + } + + void Window::focusNextElem(bool reverse) + { + // Start tabbing from the focused element if there is one, or the root + // element otherwise. + Elem *start = m_focused_elem != nullptr ? m_focused_elem : m_root_elem; + + // Loop through all the elements in order (not including the starting + // element), trying to focus them, until we reach the place we started + // again, which means that no element wanted to be focused. + Elem *next = getNextElem(start, reverse); + + while (next != start) { + if (requestFocusedElem(next, true)) { + return; + } + + next = getNextElem(next, reverse); + } + } + + void Window::focusPointedElem() + { + SDL_Event notice = createUiEvent(UI_FOCUS_REQUEST); + + // Ask all elements that the mouse just clicked on if they want to + // be the focused element. + Elem *new_focused = sendPointedInput(notice); + + // If an element responded to the request that is different from the + // currently focused element, then update the focused element. + if (new_focused != nullptr && m_focused_elem != new_focused) { + changeFocusedElem(new_focused, true); + } + } + + void Window::hoverPointedElem() + { + SDL_Event notice = createUiEvent(UI_HOVER_REQUEST); + + // If the window is focused, ask all elements that the mouse is + // currently inside if they want to be the hovered element. Otherwise, + // make no element hovered. + Elem *old_hovered = m_hovered_elem; + Elem *new_hovered = nullptr; + + if (isFocused()) { + new_hovered = sendPointedInput(notice); + } + + // If a different element responded to the hover request (or no element + // at all), then update the hovered element. + if (old_hovered != new_hovered) { + m_hovered_elem = new_hovered; + + // Let the old and new hovered elements know that things have + // changed, and their parent elements too. + notice = createUiEvent(UI_HOVER_CHANGED, old_hovered, new_hovered); + + sendTreeInput(old_hovered, notice, false); + sendTreeInput(new_hovered, notice, false); + } + } + + bool Window::isPointerOutside() const + { + // If the mouse is inside any element, it's not outside the window. We + // have to check every element, not just the root, because elements may + // have the noclip property set. However, the backdrop is not included. + for (Elem *elem : m_ordered_elems) { + if (elem->getMain().isContentPointed()) { + return false; + } + } + + return true; + } + + 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 non-empty IDs since that indicates + // a nonexistent element in getElem(). 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(); + } + + bool Window::readRootElem(std::istream &is) + { + // Get the root element of the window and make sure it's valid. + Elem *root = getElem(readNullStr(is), true); + + if (root == nullptr) { + errorstream << "Window " << m_id << " has no root element" << std::endl; + return false; + } else if (root->getType() != Elem::ROOT) { + errorstream << "Window " << m_id << + " has wrong type for root element" << std::endl; + return false; + } + + m_root_elem = static_cast(root); + return true; + } + + 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)); + } + } + + bool Window::updateElems(std::unordered_map &elem_contents, + bool set_focus, Elem *new_focused) + { + // Now that we have a fully updated window, we can update each element + // with its contents and set up the parent-child relations. 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(contents.second); + contents.first->read(is); + } + + // Check the depth of the element tree; if it's too deep, there's + // potential for stack overflow. We also create the list of ordered + // elements since we're already doing a preorder traversal. + if (!updateTree(m_root_elem, 1)) { + return false; + } + + // If the number of elements discovered by the tree traversal is less + // than the total number of elements, orphaned elements must exist. + if (m_elems.size() != m_ordered_elems.size()) { + errorstream << "Window " << m_id << " has orphaned elements" << std::endl; + return false; + } + + // If the user wants to focus a new element or unfocus the current + // element, remove focus from the current element and request focus on + // the new element. + if (set_focus && new_focused != m_focused_elem) { + if (new_focused != nullptr) { + if (!requestFocusedElem(new_focused, false)) { + changeFocusedElem(nullptr, false); + } + } else { + changeFocusedElem(nullptr, false); + } + } + return true; + } + + bool Window::updateTree(Elem *elem, size_t depth) + { + // The parent gets ordered before its children since the ordering of + // elements follows draw order. + elem->setOrder(m_ordered_elems.size()); + m_ordered_elems.push_back(elem); + + if (depth > MAX_TREE_DEPTH) { + errorstream << "Window " << m_id << + " exceeds the max tree depth of " << 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 (!updateTree(child, depth + 1)) { + return false; + } + } + + return true; + } +} diff --git a/src/ui/window.h b/src/ui/window.h new file mode 100644 index 000000000..d309251dd --- /dev/null +++ b/src/ui/window.h @@ -0,0 +1,148 @@ +// Luanti +// SPDX-License-Identifier: LGPL-2.1-or-later +// Copyright (C) 2023 v-rob, Vincent Robinson + +#pragma once + +#include "ui/elem.h" +#include "ui/helpers.h" +#include "util/basic_macros.h" + +#include +#include +#include +#include +#include + +union SDL_Event; + +namespace ui +{ + class Root; + + SizeI getTextureSize(video::ITexture *texture); + + // Serialized enum; do not change order of entries. + enum class WindowType : u8 + { + FILTER, + MASK, + HUD, + CHAT, + GUI, + + MAX = GUI, + }; + + WindowType toWindowType(u8 type); + + class Window + { + private: + // Serialized constants; do not change values of entries. + static constexpr u32 ON_CLOSE = 0x00; + static constexpr u32 ON_SUBMIT = 0x01; + static constexpr u32 ON_FOCUS_CHANGE = 0x02; + + static constexpr size_t MAX_TREE_DEPTH = 64; + + // The ID and type are intrinsic to the box's identity, so they aren't + // cleared in reset(). The ID is set by the constructor, whereas the + // type is deserialized when the window is first opened. + u64 m_id; + WindowType m_type = WindowType::GUI; + + std::unordered_map> m_elems; + std::vector m_ordered_elems; + + Root *m_root_elem; + + std::vector m_style_strs; + + Elem *m_focused_elem; + Elem *m_hovered_elem; + + bool m_allow_close; + u32 m_events; + + public: + Window(u64 id) : + m_id(id) + { + reset(); + } + + DISABLE_CLASS_COPY(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 *getNextElem(Elem *elem, bool reverse); + + Root *getRoot() { return m_root_elem; } + Elem *getFocused() { return m_focused_elem; } + Elem *getHovered() { return m_hovered_elem; } + + void clearElem(Elem *elem); + + const std::string *getStyleStr(u32 index) const; + + void reset(); + bool read(std::istream &is, bool opening); + + float getScale() const; + + SizeF getScreenSize() const; + PosF getPointerPos() const; + + SizeF getTextSize(gui::IGUIFont *font, std::wstring_view text); + + void drawRect(RectF dst, RectF clip, video::SColor color); + void drawTexture(RectF dst, RectF clip, video::ITexture *texture, + RectF src = RectF(0.0f, 0.0f, 1.0f, 1.0f), video::SColor tint = WHITE); + void drawText(RectF dst, RectF clip, gui::IGUIFont *font, std::wstring_view text, + video::SColor color = WHITE, video::SColor mark = BLANK, + Align align = Align::START, Align valign = Align::START); + + void drawAll(); + + bool isFocused() const; + bool processInput(const SDL_Event &event); + + private: + void enableEvent(u32 event); + bool testEvent(u32 event) const; + + std::ostringstream createEvent(u32 event) const; + + // Warning: This method causes the window object to be destroyed. + // Return immediately after use, and don't use the window object again. + void destroyWindow(); + + Elem *sendTreeInput(Elem *elem, const SDL_Event &event, bool direct); + Elem *sendPointedInput(const SDL_Event &event); + Elem *sendFocusedInput(const SDL_Event &event); + + void changeFocusedElem(Elem *new_focused, bool send_event); + bool requestFocusedElem(Elem *new_focused, bool send_event); + + void focusNextElem(bool reverse); + + void focusPointedElem(); + void hoverPointedElem(); + + bool isPointerOutside() const; + + void readElems(std::istream &is, + std::unordered_map &elem_contents); + bool readRootElem(std::istream &is); + void readStyles(std::istream &is); + + bool updateElems(std::unordered_map &elem_contents, + bool set_focus, Elem *new_focused); + bool updateTree(Elem *elem, size_t depth); + }; +} diff --git a/src/util/serialize.cpp b/src/util/serialize.cpp index e7a002662..179e84104 100644 --- a/src/util/serialize.cpp +++ b/src/util/serialize.cpp @@ -19,40 +19,56 @@ 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(); static_assert(STRING_MAX_LEN <= U16_MAX); - if (plain.size() > STRING_MAX_LEN) - throw SerializationError("String too long for serializeString16"); - s.reserve(2 + plain.size()); - writeU16((u8 *)&buf[0], plain.size()); - s.append(buf, 2); + if (size > STRING_MAX_LEN) { + if (truncate) { + size = STRING_MAX_LEN; + } else { + throw SerializationError("String too long for serializeString16"); + } + } + + 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: truncated"); + } return s; } @@ -62,45 +78,74 @@ 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(); static_assert(LONG_STRING_MAX_LEN <= U32_MAX); - if (plain.size() > LONG_STRING_MAX_LEN) - throw SerializationError("String too long for serializeLongString"); - s.reserve(4 + plain.size()); - writeU32((u8*)&buf[0], plain.size()); - s.append(buf, 4); - s.append(plain); + 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)); + 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: truncated"); + 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: truncated"); + } + + // 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 7da5f44d6..1edce6f79 100644 --- a/src/util/serialize.h +++ b/src/util/serialize.h @@ -446,16 +446,16 @@ MAKE_STREAM_WRITE_FXN(video::SColor, ARGB8, 4); } // 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);