minetest/builtin/ui/selector.lua

498 lines
14 KiB
Lua

--[[
Minetest
Copyright (C) 2023 v-rob, Vincent Robinson <robinsonvincent89@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation; either version 2.1 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
--]]
ui._STATE_NONE = 0
ui._NUM_STATES = bit.lshift(1, 5)
ui._NO_STYLE = -1
--[[
Selector parsing functions return a function. When called with an element as
the solitary parameter, this function will return a boolean, indicating whether
the element is matched by the selector. If the boolean is true, a table of
tables {box=..., states=...} is also returned. If false, this is nil.
The keys of this table are hashes of the box, which serve to prevent duplicate
box/state combos from being generated. The values contain all the combinations
of boxes and states that the selector specifies. The box may be nil if the
selector specified no box, in which case it should default to "main". This list
may also be empty, which means that contradictory boxes were specified and no
box should be styled. The list will not contain duplicates.
--]]
-- By default, most selectors leave the box unspecified and don't select any
-- particular state, leaving the state at zero.
local function make_box(name, states)
return {name = name, states = states or ui._STATE_NONE}
end
-- Hash the box to string that represents that combination of box and states
-- uniquely to prevent duplicates in box tables.
local function hash_box(box)
return (box.name or "") .. "$" .. tostring(box.states)
end
local function make_hashed(name, states)
local box = make_box(name, states)
return {[hash_box(box)] = box}
end
local function result(matches, name, states)
if matches then
return true, make_hashed(name, states)
end
return false, nil
end
local simple_preds = {
["empty"] = function(elem)
return result(#elem._children == 0)
end,
["first_child"] = function(elem)
return result(elem._parent == nil or elem._index == 1)
end,
["last_child"] = function(elem)
return result(elem._parent == nil or elem._rindex == 1)
end,
["only_child"] = function(elem)
return result(elem._parent == nil or #elem._parent._children == 1)
end,
}
local sel_preds = {
["<"] = function(sel)
return function(elem)
return result(elem._parent and sel(elem._parent))
end
end,
[">"] = function(sel)
return function(elem)
for _, child in ipairs(elem._children) do
if sel(child) then
return result(true)
end
end
return result(false)
end
end,
["<<"] = function(sel)
return function(elem)
local ancestor = elem._parent
while ancestor ~= nil do
if sel(ancestor) then
return result(true)
end
ancestor = ancestor._parent
end
return result(false)
end
end,
[">>"] = function(sel)
return function(elem)
for _, descendant in ipairs(elem:_get_flat()) do
if descendant ~= elem and sel(descendant) then
return result(true)
end
end
return result(false)
end
end,
["<>"] = function(sel)
return function(elem)
if not elem._parent then
return result(false)
end
for _, sibling in ipairs(elem._parent._children) do
if sibling ~= elem and sel(sibling) then
return result(true)
end
end
return result(false)
end
end,
}
local special_preds = {
["nth_child"] = function(str)
local index = tonumber(str)
assert(index, "Expected number for ?nth_child")
return function(elem)
if not elem._parent then
return result(index == 1)
end
return result(elem._index == index)
end
end,
["nth_last_child"] = function(str)
local rindex = tonumber(str)
assert(rindex, "Expected number for ?nth_last_child")
return function(elem)
if not elem._parent then
return result(rindex == 1)
end
return result(elem._rindex == rindex)
end
end,
}
local states_by_name = {
focused = bit.lshift(1, 0),
selected = bit.lshift(1, 1),
hovered = bit.lshift(1, 2),
pressed = bit.lshift(1, 3),
disabled = bit.lshift(1, 4),
}
local function parse_term(str, pred)
str = str:trim()
assert(str ~= "", "Expected selector term")
-- We need to test the first character to see what sort of term we're
-- dealing with, and then usually parse from the rest of the string.
local prefix = str:sub(1, 1)
local suffix = str:sub(2)
if prefix == "*" then
-- Universal terms match everything and have no extra stuff to parse.
return suffix, function(elem)
return result(true)
end
elseif prefix == "#" then
-- Most selectors are similar to the ID selector, in that characters
-- for the ID string are parsed, and all the characters directly
-- afterwards are returned as the rest of the string after the term.
local id, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$")
assert(id, "Expected ID after '#'")
return rest, function(elem)
return result(elem._id == id)
end
elseif prefix == "." then
local group, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$")
assert(group, "Expected group after '.'")
return rest, function(elem)
return result(elem._groups[group] ~= nil)
end
elseif prefix == "@" then
--[[
It's possible to check if a box exists in a predicate, but that leads
to different behaviors inside and outside of predicates. @main@thumb
effectively matches nothing by returning an empty table of boxes, but
will return true for scrollbars, which a predicate will interpret as
matching something. So, prevent it altogether. This problem
fundamentally exists because we select elements, not boxes, since boxes
and states are very much tied to the client-side of things.
--]]
assert(not pred, "Box selectors are invalid for predicates")
local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$")
assert(name, "Expected box after '@'")
return rest, function(elem)
if elem._boxes[name] then
-- If the box is in the element, return it.
return result(true, name, ui._STATE_NONE)
elseif name == "all" then
-- If we want all boxes, iterate over the boxes in the element
-- and add each of them to a full list of boxes.
local boxes = {}
for name in pairs(elem._boxes) do
local box = make_box(name, ui._STATE_NONE)
boxes[hash_box(box)] = box
end
return true, boxes
end
-- Otherwise, the selector doesn't match.
return result(false)
end
elseif prefix == "$" then
-- Unfortunately, we can't detect the state of boxes from the server,
-- so we can't use them in predicates.
assert(not pred, "Style selectors are invalid for predicates")
local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)(.*)$")
assert(name, "Expected state after '$'")
local state = states_by_name[name]
assert(state, "Invalid state: '" .. name .. "'")
return rest, function(elem)
-- States unconditionally match every element. Specify the state
-- that this term indicates but leave the box undefined.
return result(true, nil, state)
end
elseif prefix == "/" then
local type, rest = suffix:match("^([" .. ui._ID_CHARS .. "]+)%/(.*)$")
assert(type, "Expected window type after '/'")
assert(ui._window_types[type], "Invalid window type: '" .. type .. "'")
return rest, function(elem)
return result(elem._window._type == type)
end
elseif prefix == "," then
-- Since we don't know which terms came directly behind us, we return
-- nil so that ui._parse_sel() can union the two selectors on either
-- side of the comma instead of returning a selector function.
return suffix, nil
elseif prefix == "(" then
-- Parse a matching set of parentheses, and recursively pass the
-- contents into ui._parse_sel().
local sub, rest = str:match("^(%b())(.*)$")
assert(sub, "Unmatched ')' for '('")
return rest, ui._parse_sel(sub:sub(2, -2), pred)
elseif prefix == "!" then
-- Parse a single predicate term (NOT an entire predicate selector) and
-- ensure that it's a valid selector term, not a comma.
local rest, term = parse_term(suffix, true)
assert(term, "Expected selector term after '!'")
return rest, function(elem)
return result(not term(elem))
end
elseif prefix == "?" then
-- Predicates may have different syntax depending on the name of the
-- predicate, so just parse the name initially.
local name, rest = suffix:match("^([" .. ui._ID_CHARS .. "%<%>%^]+)(.*)$")
assert(name, "Expected predicate after '?'")
-- If this is a simple predicate, return its predicate function without
-- doing any further parsing.
local func = simple_preds[name]
if func then
return rest, func
end
-- If this is a function predicate, we need to do more parsing.
func = sel_preds[name] or special_preds[name]
if func then
-- Parse a matching pair of parentheses and get the contents
-- between them.
assert(rest:sub(1, 1) == "(", "Expected '(' after '?" .. name .. "'")
local sub, rest = rest:match("^(%b())(.*)$")
assert(sub, "Unmatched ')' for '?" .. name .. "('")
local contents = sub:sub(2, -2)
-- If this is a function predicate that wants a selector, parse the
-- contents as a predicate selector and pass it on to the selector
-- creation function.
if sel_preds[name] then
return rest, func(ui._parse_sel(contents, true))
end
-- Otherwise, hand the string directly to the function for special
-- processing, which we automatically trim for convenience.
return rest, func(contents:trim())
end
-- Otherwise, there is no predicate by this name.
error("Invalid predicate: '?" .. name .. "'")
else
-- If we found no special character, it's either a type or it indicates
-- invalid characters in the selector string.
local type, rest = str:match("^([" .. ui._ID_CHARS .. "]+)(.*)$")
assert(type, "Unexpected character in selector: '" .. prefix .. "'")
assert(ui._elem_types[type], "Invalid element type: '" .. type .. "'")
return rest, function(elem)
return result(elem._type == type)
end
end
end
local function intersect_boxes(a_boxes, b_boxes)
local new_boxes = {}
for _, box_a in pairs(a_boxes) do
for _, box_b in pairs(b_boxes) do
-- Two boxes can only be merged if they're the same box or if one
-- or both selectors hasn't specified a box yet.
if box_a.name == nil or box_b.name == nil or box_a.name == box_b.name then
-- Create the new box by taking the specified box (if there is
-- one) and ORing the states together (making them more refer
-- to a more specific state).
local new_box = make_box(
box_a.name or box_b.name,
bit.bor(box_a.states, box_b.states)
)
-- Hash this box and add it into the table. This will be
-- effectively a no-op if there's already an identical box
-- hashed in the table.
new_boxes[hash_box(new_box)] = new_box
end
end
end
return new_boxes
end
function ui._intersect_sels(sels)
return function(elem)
-- We start with the default box, and intersect the box and states from
-- every selector with it.
local all_boxes = make_hashed()
-- Loop through all of the selectors. All of them need to match for the
-- intersected selector to match.
for _, sel in ipairs(sels) do
local matches, boxes = sel(elem)
if not matches then
-- This selector doesn't match, so fail immediately.
return false, nil
end
-- Since the selector matched, intersect the boxes and states with
-- those of the other selectors. If two selectors both match an
-- element but specify different boxes, then this selector will
-- return true, but the boxes will be cancelled out in the
-- intersection, leaving an empty list of boxes.
if boxes then
all_boxes = intersect_boxes(all_boxes, boxes)
end
end
return true, all_boxes
end
end
local function union_sels(sels)
return function(elem)
-- We initially have no boxes, and have to add them in as matching
-- selectors are unioned in.
local all_boxes = {}
local found_match = false
-- Loop through all of the selectors. If any of them match, this entire
-- unioned selector matches.
for _, sel in ipairs(sels) do
local matches, boxes = sel(elem)
if matches then
-- We found a match. However, we can't return true just yet
-- because we need to union the boxes and states from every
-- selector, not just this one.
found_match = true
if boxes then
-- Add the boxes from this selector into the table of all
-- the boxes. The hashing of boxes will automatically weed
-- out any duplicates.
for hash, box in pairs(boxes) do
all_boxes[hash] = box
end
end
end
end
if found_match then
return true, all_boxes
end
return false, nil
end
end
function ui._parse_sel(str, pred)
str = str:trim()
assert(str ~= "", "Empty style selector")
-- Since selectors consisting of a single universal selector are very
-- common (as a blank ui.Style selector defaults to that), give it a
-- dedicated fast-path and skip all the parsing.
if str == "*" then
return function()
return result(true)
end
end
local sub_sels = {}
local terms = {}
-- Loop until we've read every term from the input string.
repeat
-- Parse the next term from the input string.
local term
str, term = parse_term(str, pred)
if term ~= nil then
-- If we didn't read a comma, insert this term into the list of
-- terms for the current sub-selector.
table.insert(terms, term)
else
-- If we read a comma, make sure that we have terms before and
-- after it so it's not dangling.
assert(#terms > 0, "Expected selector term before ','")
assert(str ~= "", "Expected selector term after ','")
end
-- If we read a comma or ran out of terms, we need to commit the terms
-- we've read so far.
if term == nil or str == "" then
-- If there's only one term, commit it directly. Otherwise,
-- intersect all the terms together.
if #terms == 1 then
table.insert(sub_sels, terms[1])
else
table.insert(sub_sels, ui._intersect_sels(terms))
end
-- Clear out the list of terms for the next sub-selector.
terms = {}
end
until str == ""
-- Now that we've read all the sub-selectors between the commas, we need to
-- commit them. We only need to union the terms if there's more than one.
if #sub_sels == 1 then
return sub_sels[1]
end
return union_sels(sub_sels)
end