Allow form views to reference the data model

Integrates Placeholder into Variation and Manager code to record
attempts to access the data model and evaluate them when it is
available.

- Model data is accessed through the `model` namespace.
- Elements are now available only through the `ui` namespace.
- Variant properties may be Placeholders or functions; both are
  automatically evaluated at render time.
This commit is contained in:
octacian 2020-04-03 16:33:10 -07:00
parent 4f839ce39e
commit 4849ded39a
No known key found for this signature in database
GPG Key ID: E84291D11A3509B5
8 changed files with 173 additions and 40 deletions

View File

@ -3,7 +3,8 @@ allow_defined_top = true
globals = {
"table",
"minetest"
"minetest",
"model"
}
read_globals = {
@ -14,8 +15,8 @@ read_globals = {
"vector", "ItemStack",
"dump", "DIR_DELIM", "VoxelArea", "Settings",
"libuix", "import",
"libuix", "import", "ui",
-- libuix elements
"list", "listring", "listcolors", "image", "text",
-- libuix operator replacement functions
"eq", "ne", "lt", "gt", "le", "ge", "land", "lor", "lnot"
}

View File

@ -16,13 +16,13 @@ Let's create and show a very simple formspec with just three elements:
```lua
uix:formspec("example") { w = 5, h = 5 } {
field { x = 0, y = 1, w = 5, h = 1, label = "Message:", _model = "message" },
button { x = 0, y = 2.5, w = 5, h = 1, label = "Submit", _click = "submit" },
text { x = 0, y = 4, _if = "message ~= ''", _text = "You said: ${message}" }
ui.field { x = 0, y = 1, w = 5, h = 1, label = "Message:", bind = model.message },
ui.button { x = 0, y = 2.5, w = 5, h = 1, label = "Submit", click = model.submit },
ui.text { x = 0, y = 4, visible = ne(model.message, ""), text = "You said: " .. model.message }
} {
message = "",
submit = function(self)
print("Hey! " .. self._player_name .. " submitted our form!")
submit = function()
print("Hey! " .. model._player_name .. " submitted our form!")
end
}
@ -33,7 +33,7 @@ And just in case you're not a fan of having to type the name of each property, y
```lua
{
field { 0, 1, 5, 1, "Message:", _model = "message" }
ui.field { 0, 1, 5, 1, "Message:", bind = model.message }
}
```

View File

@ -224,7 +224,7 @@ function benchmark.container_element()
local populated
Benchmark("Prepare container:", BENCHMARK_COUNT, function()
populated = Elements.container { x = 0, y = 0 } {
text { x = 0, y = 0, text = "Hello world!" }
ui.text { x = 0, y = 0, text = "Hello world!" }
}
end)

View File

@ -1,4 +1,5 @@
local utility = import("utility.lua")
local Placeholder = import("placeholder.lua")
---------------------
-- Variation Class --
@ -25,8 +26,11 @@ function Variation:new(parent, name, fields, options, child_elements)
table.constrain(options.contains, {
{"validate", "string|function"},
{"environment", "table|function", required = false},
{"environment_namespace", "string", required = false},
{"environment_ready", "boolean", required = false},
{"render", "function"},
{"render_target", "string", required = false}
{"render_target", "string", required = false},
{"bind_model", "boolean", required = false}
})
if options.contains.render_target then
@ -43,6 +47,10 @@ function Variation:new(parent, name, fields, options, child_elements)
end
end
if options and options.contains and not options.contains.environment_namespace then
options.contains.environment_namespace = "ui"
end
local instance = {
parent = parent,
name = name,
@ -76,9 +84,28 @@ function Variation:__call(def)
self.options.contains.environment = self.options.contains.environment(self)
end
local original_env
-- if an environment table is provided, add it to the global environment
if self.options.contains.environment then
setmetatable(self.options.contains.environment, { __index = _G })
if not self.options.contains.environment_ready then
-- if the environment namepsace is not disabled, nest the environment in a namespace
if self.options.contains.environment_namespace ~= "" then
local env = self.options.contains.environment
self.options.contains.environment = {}
self.options.contains.environment[self.options.contains.environment_namespace] = env
end
-- if model binding is not explicity disabled, add Placeholder listeners to the environment
if self.options.contains.bind_model ~= false then
Placeholder.new_listener(self.options.contains.environment)
else -- otherwise, inherit the entire global environment
setmetatable(self.options.contains.environment, { __index = _G })
end
self.options.contains.environment_ready = true
end
original_env = getfenv(2)
setfenv(2, self.options.contains.environment)
end
@ -93,7 +120,7 @@ function Variation:__call(def)
-- if an environment table is provided, remove it from the global environment
if self.options.contains.environment then
setfenv(2, getmetatable(self.options.contains.environment).__index)
setfenv(2, original_env)
end
return self:populate_new(def, items)
@ -137,15 +164,18 @@ function Variation:validate()
validate_err:throw("%s property '%s' is not optional", self.name, field[1])
end
-- if the value of the data stored in the definition field does not match the expected type, throw an error
if def_field ~= nil and type(def_field) ~= field[2] then
validate_err:throw("%s property '%s' must be a %s (found %s)", self.name, field[1], field[2], type(def_field))
end
-- if the value is a function or Placeholder, we can just ignore it
if not utility.check_type(def_field, "function|Placeholder") then
-- if the value of the data stored in the definition field does not match the expected type, throw an error
if def_field ~= nil and type(def_field) ~= field[2] then
validate_err:throw("%s property '%s' must be a %s (found %s)", self.name, field[1], field[2], type(def_field))
end
-- if the variation requires a specific value, check it and throw an error if it isn't satisfied
if field[3] and def_field ~= field[3] then
validate_err:throw("%s property '%s' must be a %s with value %s (found %s with value %s)", self.name, field[1],
field[2], dump(field[3]), type(def_field), dump(def_field))
-- if the variation requires a specific value, check it and throw an error if it isn't satisfied
if field[3] and def_field ~= field[3] then
validate_err:throw("%s property '%s' must be a %s with value %s (found %s with value %s)", self.name, field[1],
field[2], dump(field[3]), type(def_field), dump(def_field))
end
end
end
@ -160,12 +190,70 @@ function Variation:validate()
return true
end
local evaluate_env = {
eq = Placeholder.is.eq, ne = Placeholder.is.ne, lt = Placeholder.is.lt, gt = Placeholder.is.gt, le = Placeholder.is.le,
ge = Placeholder.is.ge, land = Placeholder.logical.l_and, lor = Placeholder.logical.l_or,
lnot = Placeholder.logical.l_not
}
local function_call_env = {}
setmetatable(evaluate_env, { __index = _G })
setmetatable(function_call_env, { __index = _G })
local evaluate_err = utility.ErrorBuilder:new("Variation:evaluate")
-- Takes the index of a field in self.fields and returns the corresponding value from the definition. If the value is a
-- placeholder or function the value is extracted and checked for validity. If the value is nil and the field is to be
-- generated, a new ID is fetched from the parent form. An error is thrown if anything is invalid.
function Variation:evaluate(form, field_index)
local field_def = self.fields[field_index]
local value = self.def[self.field_map[field_index]]
evaluate_env.model = form.model
function_call_env.model = form.model
while utility.check_type(value, "Placeholder|function") do
-- if the value is a function, call it until it isn't
while type(value) == "function" do
setfenv(value, function_call_env)
value = value()
end
-- if the value is a placeholder, evaluate it
if utility.type(value) == "Placeholder" then
value = Placeholder.evaluate(evaluate_env, value, function_call_env)
end
end
-- if the value should be generated, fetch a new ID from the parent form and return immediately
if field_def.generate and (field_def.hidden or value == nil) then
self.generated_def[field_def[1]] = tostring(form:new_id())
return self.generated_def[field_def[1]]
end
-- if the value is nil and the field is required, throw an error
if value == nil and field_def.required ~= false and field_def.hidden ~= true then
evaluate_err:throw("%s property '%s' evaluated to nil but the property is not optional", self.name, field_def[1])
end
-- if the new value does not match the expected type, throw an error
if value ~= nil and type(value) ~= field_def[2] then
evaluate_err:throw("%s property '%s' evaluated to a %s but the property requires a %s", self.name, field_def[1],
type(value), field_def[2])
end
-- if a specific value is required, check it and throw an error if it isn't satisfied
if field_def[3] and value ~= field_def[3] then
evaluate_err:throw("%s property '%s' evaluated to a %s with value %s but the property requires a %s with value %s",
self.name, field_def[1], type(value), dump(value), field_def[2], field_def[3])
end
if value == nil then return ""
else return value end
end
local render_err = utility.ErrorBuilder:new("Variation:render")
-- Renders a variation given a form as context.
function Variation:render(form)
if utility.DEBUG then utility.enforce_types({"Form"}, form) end
-- Obey _if visibility control.
-- Obey visibility control.
if self.def._if and not form.model:_evaluate(self.def._if) then
return ""
end
@ -187,13 +275,7 @@ function Variation:render(form)
handle_container_target = false
contained = ""
elseif field.internal ~= true then
local def_index = self.field_map[index]
local value = self.def[def_index]
if field.generate and (field.hidden or value == nil) then
self.generated_def[field[1]] = tostring(form:new_id())
value = self.generated_def[field[1]]
elseif value == nil then value = "" end
fieldstring = fieldstring .. tostring(value) .. separator
fieldstring = fieldstring .. tostring(self:evaluate(form, index)) .. separator
end
end

View File

@ -3,6 +3,7 @@ local utility = import("utility.lua")
local Form = import("formspec/form.lua")
local Model = import("formspec/model.lua")
local Elements = import("formspec/elements.lua")
local Placeholder = import("placeholder.lua")
---------------------------
-- FormspecManager Class --
@ -16,8 +17,10 @@ function FormspecManager:new(parent)
local instance = { parent = parent, forms = {} }
setmetatable(instance, FormspecManager)
instance.elements = Elements(instance)
setmetatable(instance.elements, { __index = _G })
local elements = Elements(instance)
instance.elements = { ui = elements }
Placeholder.new_listener(instance.elements)
instance.original_env = getfenv(2)
return instance
end
@ -33,7 +36,7 @@ function FormspecManager:__call(name)
return function(elements)
-- Accept data model table.
return function(model)
setfenv(2, getmetatable(self.elements).__index) -- Remove Elements from the global environment.
setfenv(2, self.original_env) -- Remove Elements from the global environment.
self.forms[#self.forms + 1] = Form:new(self, name, options, elements, Model:new(model))
end
end

View File

@ -6,6 +6,8 @@ local manager = mock.FormspecManager:new(nil, require("formspec/elements"))
local form = mock.Form:new()
local Variation = require("formspec/element/variation")
local Model = require("formspec/model")
local Placeholder = require("placeholder")
local utility = require("utility")
local field_name = { "name", "string" }
local Example = Variation:new(manager, "variation_spec", {
@ -72,6 +74,47 @@ describe("Variation", function()
end)
end)
describe("evaluate", function()
local Evaluate = Variation:new(manager, "evaluate_spec", {
{ "type", "number", 1 },
{ "x", "number" },
{ "name", "string", hidden = true, generate = true },
{ "text", "string" },
{ "visible", "boolean", required = false }
})
local env = mock.Form:new(nil, {
another = true, control = Placeholder.index("model").another,show = function() return model.control end
})
it("returns a field from the definition table", function()
local populated = Evaluate {
type = 1, x = 10, text = function() return function() return "Hello!" end end,
visible = Placeholder.index("model").show
}
assert.are.equal(1, populated:evaluate(env, 1))
assert.are.equal(10, populated:evaluate(env, 2))
assert.are.equal("0", populated:evaluate(env, 3))
assert.are.equal("Hello!", populated:evaluate(env, 4))
assert.is_true(populated:evaluate(env, 5))
end)
it("throws an error when values do not conform to variant rules", function()
local populated = Evaluate {
type = function() return 2 end, x = Placeholder.index("model").show; text = Placeholder.index("model").message
}
assert.has_error(function() populated:evaluate(env, 1) end, "libuix->Variation:evaluate: evaluate_spec property "
.. "'type' evaluated to a number with value 2 but the property requires a number with value 1")
assert.has_error(function() populated:evaluate(env, 2) end, "libuix->Variation:evaluate: evaluate_spec property 'x' "
.. "evaluated to a boolean but the property requires a number")
assert.has_error(function() populated:evaluate(env, 4) end, "libuix->Variation:evaluate: evaluate_spec property "
.. "'text' evaluated to nil but the property is not optional")
assert.are.equal("", populated:evaluate(env, 5))
end)
end)
describe("render", function()
it("autogenerates necessary fields", function()
local last_id = form.last_id + 1
@ -137,10 +180,13 @@ describe("Variation", function()
local populated
assert.has_no.error(function()
populated = Example { x = 15, y = 7 } {
text { x = 0, y = 0, text = "Hello!" }
ui.text { x = 0, y = model.text_y, text = "Hello!" }
}
end)
assert.are.equal("container_spec[15,7]label[0,0;Hello!]container_spec_end[]", populated:render(form))
assert.are.equal("Placeholder", utility.type(populated.items[1].def.y))
local env = mock.Form:new(nil, { text_y = 0 })
assert.are.equal("container_spec[15,7]label[0,0;Hello!]container_spec_end[]", populated:render(env))
end)
it("encapsulating custom structures", function()

View File

@ -81,7 +81,7 @@ describe("'container' element", function()
local populated
(function()
populated = manager.elements.container { x = 2, y = 2 } {
text { x = 0, y = 0, text = "Hello!" }
ui.text { x = 0, y = 0, text = "Hello!" }
}
end)()
assert.are.equal("container[2,2]label[0,0;Hello!]container_end[]", populated:render(form))

View File

@ -10,12 +10,13 @@ describe("FormspecManager", function()
local instance = FormspecManager:new(UIXInstance:new("unit_test"))
it("collects formspec name, options, elements, and model for addition to the instance", function()
assert.has_no.error(function()
-- assert.has_no.error(function()
instance("manager_spec") { w = 5, h = 10 } {
text { x = 0, y = 0, text = "Hello!" }
} {}
end)
ui.text { x = 0, y = model.text_y, text = "Hello!" }
} { text_y = 0 }
-- end)
assert.are.equal("Placeholder", utility.type(instance.forms[1].elements[1].def.y))
assert.are.equal("Hello!", instance.forms[1].elements[1].def.text)
end)