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/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/context.lua b/builtin/ui/context.lua new file mode 100644 index 000000000..58137e129 --- /dev/null +++ b/builtin/ui/context.lua @@ -0,0 +1,143 @@ +-- 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) diff --git a/builtin/ui/elem.lua b/builtin/ui/elem.lua new file mode 100644 index 000000000..9d0d6274c --- /dev/null +++ b/builtin/ui/elem.lua @@ -0,0 +1,140 @@ +-- 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 + + 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._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 + + self:_encode_box(fl, self._boxes.main) + + return ui._encode_flags(fl) +end + +function ui.Elem:_encode_box(fl, box) + -- Element encoding always happens after styles are computed and boxes are + -- populated with style indices. So, if this box has any styles applied to + -- it, encode the relevant states. + if not ui._shift_flag(fl, box.n > 0) then + return + end + + local box_fl = ui._make_flags() + + -- For each state, check if there is any styling. If there is, add it + -- to the box's flags. + for i = ui._STATE_NONE, ui._NUM_STATES - 1 do + if ui._shift_flag(box_fl, box[i] ~= ui._NO_STYLE) then + ui._encode_flag(box_fl, "I", box[i]) + end + end + + ui._encode_flag(fl, "s", ui._encode_flags(box_fl)) +end diff --git a/builtin/ui/init.lua b/builtin/ui/init.lua new file mode 100644 index 000000000..1f46d470b --- /dev/null +++ b/builtin/ui/init.lua @@ -0,0 +1,18 @@ +-- 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 .. "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..a1db3225d --- /dev/null +++ b/builtin/ui/selector.lua @@ -0,0 +1,569 @@ +-- 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 + +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..767702964 --- /dev/null +++ b/builtin/ui/style.lua @@ -0,0 +1,281 @@ +-- 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 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 + +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) + + 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_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) + + 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..9aa761335 --- /dev/null +++ b/builtin/ui/theme.lua @@ -0,0 +1,36 @@ +-- 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, + }, +} + +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..8da479f64 --- /dev/null +++ b/builtin/ui/window.lua @@ -0,0 +1,190 @@ +-- 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._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 + + 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 data = ui._encode("ZzZ", enc_elems, self._root._id, enc_styles) + if opening then + data = ui._encode("ZB", data, ui._window_types[self._type]) + end + + return data +end + +function ui.Window:_encode_styles() + -- Clear out all the boxes in every element. + for _, elem in ipairs(self._elems) do + for box in pairs(elem._boxes) do + elem._boxes[box] = {n = 0} + end + end + + -- Get a cascaded and flattened list of all the styles for this window. + local styles = self:_get_full_style():_get_flat() + + -- Take each style and apply its properties to every box and state matched + -- by its selector. + self:_apply_styles(styles) + + -- Take the styled boxes and encode their styles into a single table, + -- replacing the boxes' style property tables with indices into this table. + local enc_styles = self:_index_styles() + + return ui._encode_array("Z", enc_styles) +end + +function ui.Window:_get_full_style() + -- The full style contains the theme, global 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 diff --git a/doc/lua_api.md b/doc/lua_api.md index 100a75f0c..81a9ee495 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. @@ -5993,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 -------