diff --git a/aftman.toml b/aftman.toml index bd0ab91bc..6d365c63b 100644 --- a/aftman.toml +++ b/aftman.toml @@ -4,4 +4,5 @@ # To add a new tool, add an entry to this table. [tools] rojo = "rojo-rbx/rojo@7.4.1" -selene = "Kampfkarren/selene@0.26.1" \ No newline at end of file +selene = "Kampfkarren/selene@0.26.1" +luau-lsp = "JohnnyMorganz/luau-lsp@1.38.1" diff --git a/src/Instances/Tag.luau b/src/Instances/Tag.luau new file mode 100644 index 000000000..e4c555f2c --- /dev/null +++ b/src/Instances/Tag.luau @@ -0,0 +1,61 @@ +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + A special key for property tables, which allows users to apply custom + CollectionService tags to instances +]] + +local Package = script.Parent.Parent +local Types = require(Package.Types) +-- Memory +local checkLifetime = require(Package.Memory.checkLifetime) +-- Graph +local Observer = require(Package.Graph.Observer) +-- State +local castToState = require(Package.State.castToState) +local peek = require(Package.State.peek) + +local keyCache: { [string]: Types.SpecialKey } = {} + +-- TODO: should this accept tagName: UsedAs? +local function Tag(tagName: string): Types.SpecialKey + local key = keyCache[tagName] + if key == nil then + key = { + type = "SpecialKey", + kind = "Tag", + stage = "self", + apply = function(self: Types.SpecialKey, scope: Types.Scope, value: unknown, applyTo: Instance) + if castToState(value) then + local value = value :: Types.StateObject + checkLifetime.bOutlivesA( + scope, + applyTo, + value.scope, + value.oldestTask, + checkLifetime.formatters.boundTag, + tagName + ) + Observer(scope, value :: any):onBind(function() + if peek(value) == true then + applyTo:AddTag(tagName) + elseif applyTo:HasTag(tagName) then + applyTo:RemoveTag(tagName) + end + end) + else + if value == true then + applyTo:AddTag(tagName) + end + end + end, + } + keyCache[tagName] = key + end + return key +end + +return Tag diff --git a/src/Memory/checkLifetime.luau b/src/Memory/checkLifetime.luau index 3801a9d70..0e2fd0b2f 100644 --- a/src/Memory/checkLifetime.luau +++ b/src/Memory/checkLifetime.luau @@ -47,6 +47,16 @@ function checkLifetime.formatters.boundAttribute( return `The {boundName} (bound to the {attribute} attribute)`, `the {selfName} instance` end +function checkLifetime.formatters.boundTag( + instance: Instance, + bound: unknown, + tag: string +): (string, string) + local selfName = instance.Name + local boundName = nameOf(bound, "value") + return `The {boundName} (bound to the {tag} CollectionService tag)`, `the {selfName} instance` +end + function checkLifetime.formatters.propertyOutputsTo( instance: Instance, bound: unknown, diff --git a/src/Types.luau b/src/Types.luau index ddecd4092..4377acd0b 100644 --- a/src/Types.luau +++ b/src/Types.luau @@ -276,6 +276,7 @@ export type Fusion = { Attribute: (attributeName: string) -> SpecialKey, AttributeChange: (attributeName: string) -> SpecialKey, AttributeOut: (attributeName: string) -> SpecialKey, + Tag: (tagName: string) -> SpecialKey, } export type ExternalProvider = { diff --git a/src/init.luau b/src/init.luau index c3d87cf97..1ce6e3566 100644 --- a/src/init.luau +++ b/src/init.luau @@ -38,9 +38,9 @@ do External.setExternalProvider(RobloxExternal) end -local Fusion: Fusion = table.freeze { +local Fusion: Fusion = table.freeze({ -- General - version = {major = 0, minor = 4, isRelease = false}, + version = { major = 0, minor = 4, isRelease = false }, Contextual = require(script.Utility.Contextual), Safe = require(script.Utility.Safe), @@ -73,10 +73,11 @@ local Fusion: Fusion = table.freeze { OnChange = require(script.Instances.OnChange), OnEvent = require(script.Instances.OnEvent), Out = require(script.Instances.Out), + Tag = require(script.Instances.Tag), -- Animation Tween = require(script.Animation.Tween), Spring = require(script.Animation.Spring), -} +}) return Fusion diff --git a/test/Spec/Instances/Tag.spec.luau b/test/Spec/Instances/Tag.spec.luau new file mode 100644 index 000000000..a511ad1fa --- /dev/null +++ b/test/Spec/Instances/Tag.spec.luau @@ -0,0 +1,51 @@ +--!strict +--!nolint LocalUnused +local task = nil -- Disable usage of Roblox's task scheduler + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Fusion = ReplicatedStorage.Fusion + +local New = require(Fusion.Instances.New) +local Tag = require(Fusion.Instances.Tag) +local Value = require(Fusion.State.Value) +local doCleanup = require(Fusion.Memory.doCleanup) + +return function() + local it = getfenv().it + + it("adds tags (constant)", function() + local expect = getfenv().expect + + local scope = {} + local child = New(scope, "Folder") { + [Tag "Foo"] = true + } + expect(child:HasTag("Foo")).to.equal(true) + doCleanup(scope) + end) + + it("adds tags (state)", function() + local expect = getfenv().expect + + local scope = {} + local addTag = Value(scope, true) + local child = New(scope, "Folder") { + [Tag "Foo"] = addTag + } + expect(child:HasTag("Foo")).to.equal(true) + end) + + it("adds/removes tag when state objects are updated", function() + local expect = getfenv().expect + + local scope = {} + local tagExists = Value(scope, true) + local child = New(scope, "Folder") { + [Tag "Foo"] = tagExists + } + expect(child:HasTag("Foo")).to.equal(true) + tagExists:set(false) + expect(child:HasTag("Foo")).to.equal(false) + doCleanup(scope) + end) +end