1
0
Fork 0
mirror of https://github.com/luanti-org/luanti.git synced 2025-06-27 16:36:03 +00:00

Create Lua frontend UI code

This commit is contained in:
v-rob 2024-01-23 22:54:45 -08:00
parent abfb319e9b
commit 9cc73f16f0
13 changed files with 1646 additions and 1 deletions

View file

@ -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"}},
}

View file

@ -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

View file

@ -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)

143
builtin/ui/context.lua Normal file
View file

@ -0,0 +1,143 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2025 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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)

140
builtin/ui/elem.lua Normal file
View file

@ -0,0 +1,140 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

18
builtin/ui/init.lua Normal file
View file

@ -0,0 +1,18 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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")

569
builtin/ui/selector.lua Normal file
View file

@ -0,0 +1,569 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

View file

@ -0,0 +1,23 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2024 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

281
builtin/ui/style.lua Normal file
View file

@ -0,0 +1,281 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

36
builtin/ui/theme.lua Normal file
View file

@ -0,0 +1,36 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

120
builtin/ui/util.lua Normal file
View file

@ -0,0 +1,120 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

190
builtin/ui/window.lua Normal file
View file

@ -0,0 +1,190 @@
-- Luanti
-- SPDX-License-Identifier: LGPL-2.1-or-later
-- Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
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

View file

@ -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
-------