mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
Adds a built-in `vide.tag()` action that wraps `Instance:AddTag()` / `Instance:RemoveTag()` so tags can be applied through `create()` props. It accepts a static string or array of strings, or a reactive source returning either. The reactive variant diffs the previous and new tag sets so only the delta is applied. All tags added by the action are removed when the surrounding scope is destroyed. Includes mock support for `AddTag`/`RemoveTag`/`HasTag`/`GetTags`, tests, API reference docs, and a closing note in the actions tutorial. Co-authored-by: Cursor <cursoragent@cursor.com>
330 lines
9.2 KiB
Text
330 lines
9.2 KiB
Text
--[[
|
|
|
|
contains mock Roblox API interfaces for unit testing
|
|
|
|
]]
|
|
|
|
local Signal = {} :: any do
|
|
Signal.__index = Signal
|
|
Signal.__type = "RBXScriptSignal"
|
|
|
|
local function new_connection(signal, fn)
|
|
return {
|
|
signal = signal,
|
|
fn = fn,
|
|
|
|
Disconnect = function(self)
|
|
local i = table.find(signal.connections, self)
|
|
if not i then return end
|
|
table.remove(signal.connections, i)
|
|
end
|
|
}
|
|
end
|
|
|
|
function Signal.new()
|
|
return setmetatable({
|
|
connections = {}
|
|
}, Signal)
|
|
end
|
|
|
|
function Signal.Connect(self, fn)
|
|
local con = new_connection(self, fn)
|
|
table.insert(self.connections, con)
|
|
return con
|
|
end
|
|
|
|
function Signal.fire(self, ...)
|
|
for i = #self.connections, 1, -1 do
|
|
self.connections[i].fn(...)
|
|
end
|
|
end
|
|
end
|
|
|
|
local Instance = {} :: any do
|
|
--[[
|
|
|
|
attempt to mimic roblox engine's method of userdata reflection
|
|
proxy can gc independantly of actual instance data
|
|
proxy prevents gc of actual instance data
|
|
user code never has direct access to actual instance data, only to proxy
|
|
internal weak map kept data -> proxy
|
|
|
|
]]
|
|
|
|
type userdata = { __USERDATA: true }
|
|
|
|
type ProxyMT = {
|
|
proxy: userdata,
|
|
data: Data,
|
|
__index: any,
|
|
__newindex: any
|
|
}
|
|
|
|
type Data = {
|
|
name: string,
|
|
parent: Data?,
|
|
children: { Data },
|
|
changed: { [string]: RBXScriptSignal },
|
|
properties: { [string]: unknown },
|
|
tags: { [string]: true },
|
|
destroying: RBXScriptSignal,
|
|
class: string,
|
|
type: "Instance"
|
|
}
|
|
|
|
local function deep_clone<T>(template: T & {}): T
|
|
local t = table.clone(template :: {}) :: {}
|
|
|
|
for i, v in next, t do
|
|
if type(v) == "table" then
|
|
t[i] = deep_clone(v)
|
|
end
|
|
end
|
|
|
|
return t :: T & {}
|
|
end
|
|
|
|
local proxies = {} :: { [Data]: userdata? }
|
|
setmetatable(proxies :: any, { __mode = "v" })
|
|
|
|
-- allocate variables for the metamethods as __index and get_proxy cross refrences each other
|
|
local __index, __newindex
|
|
|
|
local function get_data(userdata: userdata): Data
|
|
local function f(userdata: userdata): ProxyMT
|
|
return getmetatable(userdata :: any)
|
|
end
|
|
|
|
return f(userdata).data
|
|
end
|
|
|
|
local function get_proxy(data: Data): userdata
|
|
return proxies[data] or (function()
|
|
local userdata = newproxy(true)
|
|
local proxy = getmetatable(userdata)
|
|
proxy.proxy = userdata
|
|
proxy.data = data
|
|
proxy.__index = __index
|
|
proxy.__newindex = __newindex
|
|
proxies[data] = userdata
|
|
return userdata
|
|
end)()
|
|
end
|
|
|
|
local function is_instance(value: unknown): boolean
|
|
local mt = getmetatable(value :: any)
|
|
return mt and mt.data and mt.data.type == "Instance"
|
|
end
|
|
|
|
local methods = {}
|
|
|
|
__index = function(userdata: userdata, property: string): ()
|
|
local data = get_data(userdata)
|
|
return if methods[property] then methods[property]
|
|
elseif property == "Name" then data.name
|
|
elseif property == "Parent" then (data.parent and get_proxy(data.parent))
|
|
elseif property == "Destroying" then data.destroying
|
|
else data.properties[property]
|
|
end
|
|
|
|
__newindex = function(userdata: userdata, property: string, value: unknown)
|
|
local data = get_data(userdata)
|
|
if property == "Name" then
|
|
if type(value) ~= "string" then error("name must be a string", 2) end
|
|
data.name = value :: string
|
|
elseif property == "Parent" then
|
|
assert(value == nil or is_instance(value), "attempt to set non-instance as parent")
|
|
local parent = data.parent
|
|
if parent then
|
|
data.parent = nil
|
|
table.remove(parent.children, table.find(parent.children, data))
|
|
end
|
|
if value then
|
|
data.parent = get_data(value :: userdata)
|
|
table.insert(get_data(value :: userdata).children, data)
|
|
end
|
|
else
|
|
data.properties[property] = value
|
|
end
|
|
|
|
if data.changed[property] then
|
|
Signal.fire(data.changed[property])
|
|
end
|
|
end
|
|
|
|
function Instance.new(class: string): Instance
|
|
local data = {
|
|
name = "UNNAMED",
|
|
parent = nil,
|
|
children = {},
|
|
changed = {},
|
|
properties = {},
|
|
tags = {},
|
|
class = class,
|
|
destroying = Signal.new() :: any,
|
|
type = "Instance" :: "Instance"
|
|
}
|
|
|
|
return get_proxy(data) :: any
|
|
end
|
|
|
|
function Instance.is_instance(value: unknown): boolean
|
|
return is_instance(value)
|
|
end
|
|
|
|
function methods.Clone(userdata: userdata): userdata
|
|
local data = get_data(userdata)
|
|
local clone_userdata = (Instance.new("") :: any) :: userdata
|
|
local clone_data = get_data(clone_userdata)
|
|
|
|
for i, v in next, deep_clone(data) do
|
|
clone_data[i] = v
|
|
end
|
|
|
|
return clone_userdata
|
|
end
|
|
|
|
function methods.FindFirstChild(userdata: userdata, target: string): userdata?
|
|
local data = get_data(userdata)
|
|
for _, child in data.children do
|
|
if child.name == target then
|
|
return get_proxy(child)
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function methods.GetChildren(userdata: userdata): { userdata }
|
|
local children = get_data(userdata).children
|
|
local userdatas = table.create(#children)
|
|
|
|
for i, child in next, children do
|
|
userdatas[i] = get_proxy(child)
|
|
end
|
|
|
|
return userdatas
|
|
end
|
|
|
|
function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal
|
|
local data = get_data(userdata)
|
|
if not data.changed[property] then
|
|
data.changed[property] = Signal.new() :: any
|
|
end
|
|
return data.changed[property]
|
|
end
|
|
|
|
function methods.AddTag(userdata: userdata, tag: string)
|
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
|
get_data(userdata).tags[tag] = true
|
|
end
|
|
|
|
function methods.RemoveTag(userdata: userdata, tag: string)
|
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
|
get_data(userdata).tags[tag] = nil
|
|
end
|
|
|
|
function methods.HasTag(userdata: userdata, tag: string): boolean
|
|
if type(tag) ~= "string" then error("tag must be a string", 2) end
|
|
return get_data(userdata).tags[tag] == true
|
|
end
|
|
|
|
function methods.GetTags(userdata: userdata): { string }
|
|
local out = {}
|
|
for name in get_data(userdata).tags do
|
|
table.insert(out, name)
|
|
end
|
|
return out
|
|
end
|
|
|
|
function methods.Destroy(userdata: userdata)
|
|
local data = get_data(userdata);
|
|
Signal.fire(data.destroying)
|
|
data.parent = nil
|
|
if data.changed["Parent"] then
|
|
Signal.fire(data.changed["Parent"])
|
|
end
|
|
end
|
|
end
|
|
|
|
local function table_to_proxy(t: any)
|
|
local proxy = newproxy(true)
|
|
local mt = getmetatable(proxy)
|
|
|
|
for i, v in getmetatable(t) do
|
|
mt[i] = v
|
|
end
|
|
|
|
function mt:__index(i)
|
|
return t[i]
|
|
end
|
|
|
|
function mt:__newindex(i, v)
|
|
t[i] = v
|
|
end
|
|
|
|
return proxy
|
|
end
|
|
|
|
local Color3 = { __type = "Color3" } :: any do
|
|
function Color3.new(r, g, b)
|
|
return table_to_proxy(setmetatable({ r = r, g = g, b = b }, Color3))
|
|
end
|
|
|
|
function Color3.__eq(a, b)
|
|
return a.r == b.r and a.g == b.g and a.b == b.b
|
|
end
|
|
end
|
|
|
|
local Vector2 = { __type = "Vector2" } :: any do
|
|
function Vector2.new(x, y)
|
|
return table_to_proxy(setmetatable({ x = x, y = y }, Vector2))
|
|
end
|
|
|
|
function Vector2.__eq(a, b)
|
|
return a.x == b.x and a.y == b.y
|
|
end
|
|
end
|
|
|
|
local UDim2 = { __type = "UDim2" } :: any do
|
|
function UDim2.new(sx, ox, sy, oy)
|
|
return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2))
|
|
end
|
|
|
|
function UDim2.fromScale(x, y)
|
|
return table_to_proxy(setmetatable({ x = { scale = x, offset = 0 }, y = { scale = y, offset = 0 } }, UDim2))
|
|
end
|
|
|
|
function UDim2.__eq(a, b)
|
|
return a.x.scale == b.x.scale and
|
|
b.x.offset == b.x.offset and
|
|
a.y.scale == b.y.scale and
|
|
a.y.offset == b.y.offset
|
|
end
|
|
end
|
|
|
|
local Enum = {} :: any do
|
|
setmetatable(Enum, { __index = function(self, index)
|
|
local v = setmetatable({}, { __index = function(self, index)
|
|
self[index] = true
|
|
return true
|
|
end})
|
|
self[index] = v
|
|
return v
|
|
end})
|
|
end
|
|
|
|
local typeof = function(v)
|
|
return if Instance.is_instance(v) then "Instance"
|
|
elseif getmetatable(v) and getmetatable(v).__type then getmetatable(v).__type
|
|
else type(v)
|
|
end :: any
|
|
|
|
return {
|
|
Signal = Signal,
|
|
Instance = Instance :: typeof(Instance),
|
|
Color3 = Color3 :: typeof(Color3),
|
|
Vector2 = Vector2 :: typeof(Vector2),
|
|
UDim2 = UDim2 :: typeof(UDim2),
|
|
Enum = Enum :: typeof(Enum),
|
|
typeof = typeof :: typeof(typeof)
|
|
}
|