1
0
Fork 0
mirror of https://github.com/luanti-org/luanti.git synced 2025-06-27 16:36:03 +00:00
This commit is contained in:
Vincent Robinson 2025-06-27 02:19:02 -07:00 committed by GitHub
commit b36fe7aff6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 8538 additions and 108 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

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

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)

View file

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

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

@ -0,0 +1,187 @@
-- 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)
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

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

@ -0,0 +1,163 @@
-- 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
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

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

@ -0,0 +1,19 @@
-- 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 .. "clickable_elems.lua")
dofile(UI_PATH .. "static_elems.lua")
dofile(UI_PATH .. "window.lua")
dofile(UI_PATH .. "context.lua")
dofile(UI_PATH .. "theme.lua")

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

@ -0,0 +1,582 @@
-- 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
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

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

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

@ -0,0 +1,347 @@
-- 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 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

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

@ -0,0 +1,40 @@
-- 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,
},
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

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

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

@ -0,0 +1,283 @@
-- 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._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

View file

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

2087
doc/experimental_ui_api.md Normal file

File diff suppressed because it is too large Load diff

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

View file

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

View file

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

View file

@ -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<s32>(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<uintptr_t>(SDL_event.user.data1);
irrevent.UserEvent.UserData2 = reinterpret_cast<uintptr_t>(SDL_event.user.data2);
postEventFromUser(irrevent);
break;
case SDL_FINGERDOWN:
@ -961,8 +959,6 @@ bool CIrrDeviceSDL::run()
irrevent.TouchInput.Y = static_cast<s32>(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<s32>(SDL_event.tfinger.x * Width);
irrevent.TouchInput.Y = static_cast<s32>(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

View file

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

View file

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

View file

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

View file

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

View file

@ -64,6 +64,9 @@
#if USE_SOUND
#include "client/sound/sound_openal.h"
#endif
#if BUILD_UI
#include "ui/manager.h"
#endif
#include <csignal>
@ -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<GameUI> m_game_ui;
irr_ptr<GUIChatConsole> gui_chat_console;
#if BUILD_UI
irr_ptr<ui::GUIManagerElem> gui_manager_elem;
#endif
MapDrawControl *draw_control = nullptr;
Camera *camera = nullptr;
irr_ptr<Clouds> 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<GUIChatConsole>(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<ui::GUIManagerElem>(
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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,10 @@
#include "client/shadows/dynamicshadowsrender.h"
#include <IGUIEnvironment.h>
#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();
}

View file

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

View file

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

View file

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

View file

@ -7,12 +7,17 @@
/*
All kinds of stuff that needs to be exposed from main.cpp
*/
#include "config.h"
#include "modalMenu.h"
#include <cassert>
#include <list>
#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;
if (!m_stack.empty()) {
GUIModalMenu *mm = dynamic_cast<GUIModalMenu*>(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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<std::string> &result);

View file

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

View file

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

View file

@ -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);
} 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<std::string>(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<std::string>(L, arg);
os << serializeString16(str, true);
break;
}
case 'S': {
std::string str = readParam<std::string>(L, arg);
os << serializeString32(str, true);
break;
}
case 'z': {
std::string str = readParam<std::string>(L, arg);
os << std::string_view(str.c_str(), strlen(str.c_str())) << '\0';
break;
}
case 'Z':
os << readParam<std::string>(L, arg);
break;
case ' ':
// Continue because we don't want to increment arg.
continue;
default:
throw LuaError("Invalid format string");
}
return 0;
arg++;
}
std::string data = os.str();
lua_pushlstring(L, data.c_str(), data.size());
return 1;
}
// decode_network(format, data)
int ModApiUtil::l_decode_network(lua_State *L)
{
NO_MAP_LOCK_REQUIRED;
std::string format = readParam<std::string>(L, 1);
std::string data = readParam<std::string>(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);

View file

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

View file

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

View file

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

10
src/ui/CMakeLists.txt Normal file
View file

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

692
src/ui/box.cpp Normal file
View file

@ -0,0 +1,692 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#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 <SDL2/SDL.h>
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);
}
}
}

144
src/ui/box.h Normal file
View file

@ -0,0 +1,144 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/helpers.h"
#include "ui/style.h"
#include "util/basic_macros.h"
#include <array>
#include <iostream>
#include <string>
#include <vector>
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<Box *> m_content;
std::string_view m_label;
Style m_style;
std::array<u32, NUM_STATES> 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<Box *> &getContent() const { return m_content; }
void setContent(std::vector<Box *> 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);
};
}

173
src/ui/clickable_elems.cpp Normal file
View file

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

102
src/ui/clickable_elems.h Normal file
View file

@ -0,0 +1,102 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2024 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/box.h"
#include "ui/elem.h"
#include "ui/helpers.h"
#include <iostream>
#include <string>
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);
};
}

152
src/ui/elem.cpp Normal file
View file

@ -0,0 +1,152 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#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 <SDL2/SDL.h>
namespace ui
{
std::unique_ptr<Elem> Elem::create(Type type, Window &window, std::string id)
{
std::unique_ptr<Elem> elem = nullptr;
#define CREATE(name, type) \
case name: \
elem = std::make_unique<type>(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<Box *> 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;
}
}
}
}

114
src/ui/elem.h Normal file
View file

@ -0,0 +1,114 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/box.h"
#include "ui/helpers.h"
#include "util/basic_macros.h"
#include <iostream>
#include <memory>
#include <string>
#include <vector>
union SDL_Event;
namespace ui
{
class Window;
#define UI_CALLBACK(method) \
[](Elem &elem) { \
static_cast<decltype(*this)>(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<Elem *> 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<Elem> 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<Elem *> &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);
};
}

516
src/ui/helpers.h Normal file
View file

@ -0,0 +1,516 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2024 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "irrlichttypes.h"
#include "util/serialize.h"
#include <dimension2d.h>
#include <IGUIFont.h>
#include <ITexture.h>
#include <rect.h>
#include <vector2d.h>
#include <vector3d.h>
#include <algorithm>
#include <sstream>
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<typename E> struct Pos;
template<typename E> struct Size;
template<typename E> struct Rect;
template<typename E> struct Disp;
template<typename E>
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<typename K>
explicit Size(Pos<K> pos) : W(pos.X), H(pos.Y) {}
template<typename K>
Size(Size<K> other) : W(other.W), H(other.H) {}
template<typename K>
explicit Size(core::vector2d<K> vec) : W(vec.X), H(vec.Y) {}
template<typename K>
Size(core::dimension2d<K> dim) : W(dim.Width), H(dim.Height) {}
template<typename K>
explicit operator core::vector2d<K>() const { return core::vector2d<K>(W, H); }
template<typename K>
operator core::dimension2d<K>() const { return core::dimension2d<K>(W, H); }
E area() const { return W * H; }
bool empty() const { return area() == 0; }
bool operator==(Size<E> other) const { return W == other.W && H == other.H; }
bool operator!=(Size<E> other) const { return !(*this == other); }
E &operator[](int index) { return index ? H : W; }
const E &operator[](int index) const { return index ? H : W; }
Size<E> operator+() const { return Size<E>(+W, +H); }
Size<E> operator-() const { return Size<E>(-W, -H); }
Size<E> operator+(Size<E> other) const { return Size<E>(W + other.W, H + other.H); }
Size<E> operator-(Size<E> other) const { return Size<E>(W - other.W, H - other.H); }
Size<E> &operator+=(Size<E> other) { *this = *this + other; return *this; }
Size<E> &operator-=(Size<E> other) { *this = *this - other; return *this; }
Size<E> operator*(Size<E> other) const { return Size<E>(W * other.W, H * other.H); }
Size<E> operator/(Size<E> other) const { return Size<E>(W / other.W, H / other.H); }
Size<E> &operator*=(Size<E> other) { *this = *this * other; return *this; }
Size<E> &operator/=(Size<E> other) { *this = *this / other; return *this; }
Size<E> operator*(E scalar) const { return Size<E>(W * scalar, H * scalar); }
Size<E> operator/(E scalar) const { return Size<E>(W / scalar, H / scalar); }
Size<E> &operator*=(E scalar) { *this = *this * scalar; return *this; }
Size<E> &operator/=(E scalar) { *this = *this / scalar; return *this; }
Size<E> min(Size<E> other) const
{ return Size<E>(std::min(W, other.W), std::min(H, other.H)); }
Size<E> max(Size<E> other) const
{ return Size<E>(std::max(W, other.W), std::max(H, other.H)); }
Size<E> clamp(Size<E> lo, Size<E> hi) const
{ return Size<E>(std::clamp(W, lo.W, hi.W), std::clamp(H, lo.H, hi.H)); }
Size<E> clamp(Disp<E> disp) const
{ return clamp(disp.TopLeft, disp.BottomRight); }
Size<E> clip() const { return max(Size<E>()); }
friend std::ostream &operator<<(std::ostream &os, Size<E> size)
{
os << "(" << size.W << ", " << size.H << ")";
return os;
}
};
using SizeI = Size<s32>;
using SizeU = Size<u32>;
using SizeF = Size<f32>;
template<typename E>
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<typename K>
Pos(Pos<K> other) : X(other.X), Y(other.Y) {}
template<typename K>
explicit Pos(Size<K> size) : X(size.W), Y(size.H) {}
template<typename K>
Pos(core::vector2d<K> vec) : X(vec.X), Y(vec.Y) {}
template<typename K>
explicit Pos(core::dimension2d<K> dim) : X(dim.Width), Y(dim.Height) {}
template<typename K>
operator core::vector2d<K>() const { return core::vector2d<K>(X, Y); }
template<typename K>
explicit operator core::dimension2d<K>() const { return core::dimension2d<K>(X, Y); }
bool operator==(Pos<E> other) const { return X == other.X && Y == other.Y; }
bool operator!=(Pos<E> other) const { return !(*this == other); }
E &operator[](int index) { return index ? Y : X; }
const E &operator[](int index) const { return index ? Y : X; }
Pos<E> operator+(Size<E> size) const { return Pos<E>(X + size.W, Y + size.H); }
Pos<E> operator-(Size<E> size) const { return Pos<E>(X - size.W, Y - size.H); }
Pos<E> &operator+=(Size<E> size) { *this = *this + size; return *this; }
Pos<E> &operator-=(Size<E> size) { *this = *this - size; return *this; }
Pos<E> operator*(Size<E> size) const { return Pos<E>(X * size.W, Y * size.H); }
Pos<E> operator/(Size<E> size) const { return Pos<E>(X / size.W, Y / size.H); }
Pos<E> &operator*=(Size<E> size) { *this = *this * size; return *this; }
Pos<E> &operator/=(Size<E> size) { *this = *this / size; return *this; }
Pos<E> operator*(E scalar) const { return Pos<E>(X * scalar, Y * scalar); }
Pos<E> operator/(E scalar) const { return Pos<E>(X / scalar, Y / scalar); }
Pos<E> &operator*=(E scalar) { *this = *this * scalar; return *this; }
Pos<E> &operator/=(E scalar) { *this = *this / scalar; return *this; }
Size<E> operator-(Pos<E> other) const { return Size<E>(X - other.X, Y - other.Y); }
Size<E> operator/(Pos<E> other) const { return Size<E>(X / other.X, Y / other.Y); }
Pos<E> min(Pos<E> other) const
{ return Pos<E>(std::min(X, other.X), std::min(Y, other.Y)); }
Pos<E> max(Pos<E> other) const
{ return Pos<E>(std::max(X, other.X), std::max(Y, other.Y)); }
Pos<E> clamp(Pos<E> lo, Pos<E> hi) const
{ return Pos<E>(std::clamp(X, lo.X, hi.X), std::clamp(Y, lo.Y, hi.Y)); }
Pos<E> clamp(Rect<E> rect) const
{ return clamp(rect.TopLeft, rect.BottomRight); }
friend std::ostream &operator<<(std::ostream &os, Pos<E> pos)
{
os << "(" << pos.X << ", " << pos.Y << ")";
return os;
}
};
using PosI = Pos<s32>;
using PosU = Pos<u32>;
using PosF = Pos<f32>;
template<typename E>
struct Disp
{
union {
struct {
E L;
E T;
};
Size<E> TopLeft;
};
union {
struct {
E R;
E B;
};
Size<E> 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<E> size) : TopLeft(size), BottomRight(size) {}
Disp(Size<E> tl, Size<E> br) : TopLeft(tl), BottomRight(br) {}
template<typename K>
explicit Disp(Rect<K> rect) : TopLeft(rect.TopLeft), BottomRight(rect.BottomRight) {}
template<typename K>
Disp(Disp<K> other) : TopLeft(other.TopLeft), BottomRight(other.BottomRight) {}
template<typename K>
explicit Disp(core::rect<K> rect) :
TopLeft(rect.UpperLeftCorner), BottomRight(rect.LowerRightCorner) {}
template<typename K>
explicit operator core::rect<K>() const { return core::rect<K>(Rect<K>(*this)); }
E X() const { return L + R; }
E Y() const { return T + B; }
Size<E> extents() const { return TopLeft + BottomRight; }
bool operator==(Disp<E> other) const
{ return TopLeft == other.TopLeft && BottomRight == other.BottomRight; }
bool operator!=(Disp<E> other) const { return !(*this == other); }
Disp<E> operator+() const { return Disp<E>(+TopLeft, +BottomRight); }
Disp<E> operator-() const { return Disp<E>(-TopLeft, -BottomRight); }
Disp<E> operator+(Disp<E> other) const
{ return Disp<E>(TopLeft + other.TopLeft, BottomRight + other.BottomRight); }
Disp<E> operator-(Disp<E> other) const
{ return Disp<E>(TopLeft - other.TopLeft, BottomRight - other.BottomRight); }
Disp<E> &operator+=(Disp<E> other) { *this = *this + other; return *this; }
Disp<E> &operator-=(Disp<E> other) { *this = *this - other; return *this; }
Disp<E> operator*(Disp<E> other) const
{ return Disp<E>(TopLeft * other.TopLeft, BottomRight * other.BottomRight); }
Disp<E> operator/(Disp<E> other) const
{ return Disp<E>(TopLeft / other.TopLeft, BottomRight / other.BottomRight); }
Disp<E> &operator*=(Disp<E> other) { *this = *this * other; return *this; }
Disp<E> &operator/=(Disp<E> other) { *this = *this / other; return *this; }
Disp<E> operator*(E scalar) const
{ return Disp<E>(TopLeft * scalar, BottomRight * scalar); }
Disp<E> operator/(E scalar) const
{ return Disp<E>(TopLeft / scalar, BottomRight / scalar); }
Disp<E> &operator*=(E scalar) { *this = *this * scalar; return *this; }
Disp<E> &operator/=(E scalar) { *this = *this / scalar; return *this; }
Disp<E> clip() const { return Disp<E>(TopLeft.clip(), BottomRight.clip()); }
friend std::ostream &operator<<(std::ostream &os, Disp<E> disp)
{
os << "(" << disp.L << ", " << disp.T << ", " << disp.R << ", " << disp.B << ")";
return os;
}
};
using DispI = Disp<s32>;
using DispU = Disp<u32>;
using DispF = Disp<f32>;
template<typename E>
struct Rect
{
union {
struct {
E L;
E T;
};
Pos<E> TopLeft;
};
union {
struct {
E R;
E B;
};
Pos<E> 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<E> pos) : TopLeft(pos), BottomRight(pos) {}
Rect(Pos<E> tl, Pos<E> br) : TopLeft(tl), BottomRight(br) {}
explicit Rect(Size<E> size) : TopLeft(), BottomRight(size) {}
Rect(Pos<E> pos, Size<E> size) : TopLeft(pos), BottomRight(pos + size) {}
template<typename K>
Rect(Rect<K> other) : TopLeft(other.TopLeft), BottomRight(other.BottomRight) {}
template<typename K>
explicit Rect(Disp<K> disp) : TopLeft(disp.TopLeft), BottomRight(disp.BottomRight) {}
template<typename K>
Rect(core::rect<K> rect) :
TopLeft(rect.UpperLeftCorner), BottomRight(rect.LowerRightCorner) {}
template<typename K>
operator core::rect<K>() const { return core::rect<K>(TopLeft, BottomRight); }
E W() const { return R - L; }
E H() const { return B - T; }
Size<E> size() const { return BottomRight - TopLeft; }
E area() const { return size().area(); }
bool empty() const { return size().empty(); }
bool operator==(Rect<E> other) const
{ return TopLeft == other.TopLeft && BottomRight == other.BottomRight; }
bool operator!=(Rect<E> other) const { return !(*this == other); }
Rect<E> operator+(Disp<E> disp) const
{ return Rect<E>(TopLeft + disp.TopLeft, BottomRight + disp.BottomRight); }
Rect<E> operator-(Disp<E> disp) const
{ return Rect<E>(TopLeft - disp.TopLeft, BottomRight - disp.BottomRight); }
Rect<E> &operator+=(Disp<E> disp) { *this = *this + disp; return *this; }
Rect<E> &operator-=(Disp<E> disp) { *this = *this - disp; return *this; }
Rect<E> operator*(Disp<E> disp) const
{ return Rect<E>(TopLeft * disp.TopLeft, BottomRight * disp.BottomRight); }
Rect<E> operator/(Disp<E> disp) const
{ return Rect<E>(TopLeft / disp.TopLeft, BottomRight / disp.BottomRight); }
Rect<E> &operator*=(Disp<E> disp) { *this = *this * disp; return *this; }
Rect<E> &operator/=(Disp<E> disp) { *this = *this / disp; return *this; }
Rect<E> operator*(E scalar) const
{ return Rect<E>(TopLeft * scalar, BottomRight * scalar); }
Rect<E> operator/(E scalar) const
{ return Rect<E>(TopLeft / scalar, BottomRight / scalar); }
Rect<E> &operator*=(E scalar) { *this = *this * scalar; return *this; }
Rect<E> &operator/=(E scalar) { *this = *this / scalar; return *this; }
Disp<E> operator-(Rect<E> other) const
{ return Disp<E>(TopLeft - other.TopLeft, BottomRight - other.BottomRight); }
Disp<E> operator/(Rect<E> other) const
{ return Disp<E>(TopLeft / other.TopLeft, BottomRight / other.BottomRight); }
Rect<E> insetBy(Disp<E> disp) const
{ return Rect<E>(TopLeft + disp.TopLeft, BottomRight - disp.BottomRight); }
Rect<E> outsetBy(Disp<E> disp) const
{ return Rect<E>(TopLeft - disp.TopLeft, BottomRight + disp.BottomRight); }
Rect<E> unionWith(Rect<E> other) const
{ return Rect<E>(TopLeft.min(other.TopLeft), BottomRight.max(other.BottomRight)); }
Rect<E> intersectWith(Rect<E> other) const
{ return Rect<E>(TopLeft.max(other.TopLeft), BottomRight.min(other.BottomRight)); }
Rect<E> clip() const { return Rect<E>(TopLeft, size().clip()); }
bool contains(Pos<E> pos) const
{ return pos.X >= L && pos.Y >= T && pos.X < R && pos.Y < B; }
friend std::ostream &operator<<(std::ostream &os, Rect<E> rect)
{
os << "(" << rect.L << ", " << rect.T << ", " << rect.R << ", " << rect.B << ")";
return os;
}
};
using RectI = Rect<s32>;
using RectU = Rect<u32>;
using RectF = Rect<f32>;
// 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); }
}

172
src/ui/manager.cpp Normal file
View file

@ -0,0 +1,172 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#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 <SDL2/SDL.h>
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;
}

125
src/ui/manager.h Normal file
View file

@ -0,0 +1,125 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/helpers.h"
#include "ui/window.h"
#include "util/basic_macros.h"
#include <IGUIElement.h>
#include <iostream>
#include <map>
#include <string>
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<u64, Window> 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<u64, Window *> 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();
}
};
}

38
src/ui/static_elems.cpp Normal file
View file

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

38
src/ui/static_elems.h Normal file
View file

@ -0,0 +1,38 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2024 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/box.h"
#include "ui/elem.h"
#include "ui/helpers.h"
#include <iostream>
#include <string>
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;
};
}

242
src/ui/style.cpp Normal file
View file

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

160
src/ui/style.h Normal file
View file

@ -0,0 +1,160 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2024 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/helpers.h"
#include <iostream>
#include <string>
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);
};
}

812
src/ui/window.cpp Normal file
View file

@ -0,0 +1,812 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#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 <SDL2/SDL.h>
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 *, std::string> 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 *, std::string> &elem_contents)
{
// Read in all the new elements and updates to existing elements.
u32 num_elems = readU32(is);
std::unordered_map<std::string, std::unique_ptr<Elem>> 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> 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 *>(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 *, std::string> &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;
}
}

148
src/ui/window.h Normal file
View file

@ -0,0 +1,148 @@
// Luanti
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
#pragma once
#include "ui/elem.h"
#include "ui/helpers.h"
#include "util/basic_macros.h"
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
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<std::string, std::unique_ptr<Elem>> m_elems;
std::vector<Elem *> m_ordered_elems;
Root *m_root_elem;
std::vector<std::string> 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<Elem *> &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 *, std::string> &elem_contents);
bool readRootElem(std::istream &is);
void readStyles(std::istream &is);
bool updateElems(std::unordered_map<Elem *, std::string> &elem_contents,
bool set_focus, Elem *new_focused);
bool updateTree(Elem *elem, size_t depth);
};
}

View file

@ -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)
if (size > STRING_MAX_LEN) {
if (truncate) {
size = STRING_MAX_LEN;
} else {
throw SerializationError("String too long for serializeString16");
s.reserve(2 + plain.size());
}
}
writeU16((u8 *)&buf[0], plain.size());
s.append(buf, 2);
char size_buf[2];
writeU16((u8 *)size_buf, size);
s.reserve(2 + size);
s.append(size_buf, 2);
s.append(plain.substr(0, size));
s.append(plain);
return s;
}
std::string deSerializeString16(std::istream &is)
std::string deSerializeString16(std::istream &is, bool truncate)
{
std::string s;
char buf[2];
char size_buf[2];
is.read(buf, 2);
if (is.gcount() != 2)
throw SerializationError("deSerializeString16: size not read");
u16 s_size = readU16((u8 *)buf);
if (s_size == 0)
is.read(size_buf, 2);
if (is.gcount() != 2) {
if (truncate) {
return s;
}
throw SerializationError("deSerializeString16: size not read");
}
s.resize(s_size);
is.read(&s[0], s_size);
if (is.gcount() != s_size)
u16 size = readU16((u8 *)size_buf);
if (size == 0) {
return s;
}
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)
is.read(size_buf, 4);
if (is.gcount() != 4) {
if (truncate) {
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");
}
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;
}

View file

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