From 795ff8725cfc5909358ad407438d912a35c1bf07 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 3 Aug 2023 11:52:05 +0100 Subject: [PATCH] --- docs/tut/crash-course.md | 8 +++ docs/tut/reactive-graph.md | 99 -------------------------------------- docs/tut/tmp.md | 20 -------- src/action.luau | 25 ++++++++++ src/apply.luau | 34 +++++++++++-- src/derive.luau | 6 +-- src/init.luau | 18 +++---- src/map.luau | 4 +- src/source.luau | 1 - src/spring.luau | 61 ++++++++++++++--------- test/syntax.luau | 43 ----------------- test/tests.luau | 88 ++++++++++----------------------- todo.md | 52 +++++--------------- 13 files changed, 145 insertions(+), 314 deletions(-) delete mode 100644 docs/tut/reactive-graph.md delete mode 100644 docs/tut/tmp.md create mode 100644 src/action.luau delete mode 100644 test/syntax.luau diff --git a/docs/tut/crash-course.md b/docs/tut/crash-course.md index a7dd230..2f0c3a0 100644 --- a/docs/tut/crash-course.md +++ b/docs/tut/crash-course.md @@ -15,6 +15,14 @@ to maintain. Vide achieves this using a reactive style of programming which allows you to focus on the flow of data through your application without worrying about manually updating UI instances. +Some of the main focuses behind Vide's design choices: + +- Concise syntax to reduce verbosity as much as possible. +- Reducing the amount of imports needed for usage by using Luau's syntax and + semantics. +- Being completely typecheckable. +- Flexibility, particularly with integrating other libraries. + ## Creating UI Instances Instances are created using [`create()`](../api/creation#create). diff --git a/docs/tut/reactive-graph.md b/docs/tut/reactive-graph.md deleted file mode 100644 index cc0e4ca..0000000 --- a/docs/tut/reactive-graph.md +++ /dev/null @@ -1,99 +0,0 @@ -# Vide Reactive Graph - -Details on how Vide's reactive graph works. - -## Nodes - -A "node" refers to a point on the reactive graph. - -- Nodes can have parents and children. -- Updating a node will mark all descendant nodes for update. -- Each Vide state object acts as a node on the reactive graph. - -Vide's reactive graph uses a *lazy evaluation* model, meaning that -if a node with children is updated, the new value for the child node -is not recalculated immediately. Only when something attempts to access -the child's value is it recalculated. - -## Example - -Below is an (*overengineered*) example to demonstrate how the reactive graph functions. -States are used here to model the various transforms done on two inputs, `health` and `maxHealth` -to represent player health for UI. - -```lua -local health = wrap(90) -local maxHealth = wrap(100) - -local healthTweened = spring(health, 0.5) -local text = "Health: " .. healthTweened - -local ratio = health / maxHealth - -local barSize = derive(function(from) - return UDim2.fromScale(from(ratio), 1) -end -``` - -Below is a graphical representation of the reactive graph formed by the above code. - -```mermaid -flowchart LR - A(( )) - B(( )) - A --> health - B --> maxHealth - - health --> healthTweened - healthTweened --> text - - health --> ratio - maxHealth --> ratio - ratio --> barSize -``` - -When states are initially derived, all values are known. - -Say if the player is damaged, and the `health` node changes value. -All descendant nodes from `health` will be marked as updated. -The nodes marked as updated are represented by the broken lines below. - -```mermaid -flowchart LR - A(( )) - B(( )) - A --> health - B --> maxHealth - - health .-x healthTweened - healthTweened .-x text - - health .-x ratio - maxHealth --> ratio - ratio .-x barSize -``` - -When something tries to read the value of the node `text`, a recalculation occurs. -While `text` is being recalculated, `healthTweened` will be read from, causing it to be recalculated as well. -This results in a chain that propogates up the reactive graph until all ancestors are up to date. - -Below is what the graph will look like after `text` has been recalculated. - -```mermaid -flowchart LR - A(( )) - B(( )) - A --> health - B --> maxHealth - - health --> healthTweened - healthTweened --> text - - health .-x ratio - maxHealth --> ratio - ratio .-x barSize -``` - -Lazy evaluation is a useful model as it saves unecessary calculation, only calculating when needed. - -Looking at stateful code as a reactive graph is a good way to mentally picture how your data maps to UI. diff --git a/docs/tut/tmp.md b/docs/tut/tmp.md deleted file mode 100644 index e0e0091..0000000 --- a/docs/tut/tmp.md +++ /dev/null @@ -1,20 +0,0 @@ -```lua -local function Text(args) - return create("TextLabel") { - [Layout] = { - Size = scale(1), - args[Layout] - } - } -end - -Text { - [Layout] = { - Position = scale(0.5, 0.1) - } -} -``` - -```lua -a -``` diff --git a/src/action.luau b/src/action.luau new file mode 100644 index 0000000..88f4a22 --- /dev/null +++ b/src/action.luau @@ -0,0 +1,25 @@ +type Action = { + priority: number, + callback: (Instance) -> () +} + +local ActionMT = {} + +local function is_action(v: any) + return getmetatable(v) == ActionMT +end + +local function action(callback: (Instance) -> (), priority: number?): Action + local t = { + priority = priority or 1, + callback = callback + } + + setmetatable(t :: any, ActionMT) + + return t +end + +return function() + return action, is_action +end diff --git a/src/apply.luau b/src/apply.luau index ed5e945..b0408ae 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -8,15 +8,30 @@ type Node = graph.Node local throw = require(script.Parent.throw) local bind = require(script.Parent.bind) +local _, is_action = require(script.Parent.action)() -local function recurse(instance: Instance, properties: { [unknown]: unknown }, event_buffer) +local event_buffer: { [string]: () -> () } = {} +local action_buffers = {} :: { { () -> () } } + +setmetatable(action_buffers :: any, { + __index = function(_, i: number) + action_buffers[i] = {} + return action_buffers[i] + end +}) + +local function recurse(instance: Instance, properties: { [unknown]: unknown }) for property, value in properties do if type(value) == "table" then - recurse(instance, value :: {}, event_buffer) + if is_action(value) then + table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ()) + else + recurse(instance, value :: {}) + end elseif type(property) == "string" then if type(value) == "function" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then - event_buffer[property] = value + event_buffer[property] = value :: () -> () else bind.property(instance, property, value :: () -> ()) end @@ -37,14 +52,23 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown local parent: unknown = properties.Parent if parent then properties.Parent = nil end - local event_buffer: { [string]: () -> () } = {} -- connect events after setting properties + table.clear(event_buffer) + for _, buffer in next, action_buffers do + table.clear(buffer) + end - recurse(instance, properties, event_buffer) + recurse(instance, properties) for event, fn in next, event_buffer do (instance :: any)[event]:Connect(fn) end + for _, buffer in next, action_buffers do + for _, callback in next, buffer do + callback() + end + end + if parent then if type(parent) == "function" then error("cannot set parent to state") diff --git a/src/derive.luau b/src/derive.luau index cda2f61..7a05384 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -6,13 +6,11 @@ local get = graph.get local capture_and_link = graph.capture_and_link local function derive(fn: () -> T): () -> T - local node = create((nil :: any) :: T) + local node, node_get = create((nil :: any) :: T) node.cache = capture_and_link(node, fn) - return function() - return get(node) - end + return node_get end return derive diff --git a/src/init.luau b/src/init.luau index 56d77f0..9802013 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,7 +1,7 @@ --------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- -- vide.luau -- v0.1.0 --------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- if not game then script = (require :: any) "test/wrap-require" end @@ -12,12 +12,10 @@ local cleanup, clean_garbage = require(script.cleanup)() local derive = require(script.derive) local map = require(script.map) local spring, update_springs = require(script.spring)() +local action = require(script.action)() local flags = require(script.flags) -type Map = { [K]: V } -type Setter = ( (new: T, force: true?) -> T ) & ( (update: (old: T) -> T, force: true?) -> T ) - local vide = { -- core create = create, @@ -30,17 +28,13 @@ local vide = { -- animations spring = spring, - -- symbols - -- Event = Event, - -- Changed = Changed, - -- Layout = Layout, - -- Children = Children, - -- Created = Created, + -- actions + action = action, -- flags strict = (nil :: any) :: boolean, - -- test + -- runtime step = function(dt: number) update_springs(dt) clean_garbage() diff --git a/src/map.luau b/src/map.luau index 6f3b3f8..ebf9530 100644 --- a/src/map.luau +++ b/src/map.luau @@ -49,7 +49,7 @@ local function map(input: () -> Map, transform: (() -> VI, K) return recompute(input()) end - local output, get_output = create(output_cache) + local output, output_get = create(output_cache) local nodes, value = capture(input) @@ -59,7 +59,7 @@ local function map(input: () -> Map, transform: (() -> VI, K) output.cache = recompute(value) - return get_output + return output_get end return map diff --git a/src/source.luau b/src/source.luau index 3b2aaf9..93ec8ef 100644 --- a/src/source.luau +++ b/src/source.luau @@ -3,7 +3,6 @@ if not game then script = require "test/wrap-require" end local graph = require(script.Parent.graph) type Node = graph.Node local create = graph.create -local get = graph.get local set = graph.set diff --git a/src/spring.luau b/src/spring.luau index c5c682e..2c44b0c 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,21 +1,21 @@ if not game then script = (require :: any) "test/wrap-require" end --[[ - Spring animation library adapted from RDL::spring v1.0 - Supported datatypes: - *number - !bool - *CFrame - ?Rect - *Color3 - *UDim - *UDim2 - *Vector2 - !Vector2int16 - *Vector3 - !Vector3int16 - !EnumItem +Supported datatypes: +*number +!bool +*CFrame +?Rect +*Color3 +*UDim +*UDim2 +*Vector2 +!Vector2int16 +*Vector3 +!Vector3int16 +!EnumItem + ]] local throw = require(script.Parent.throw) @@ -24,6 +24,8 @@ local graph = require(script.Parent.graph) local create = graph.create local get = graph.get local set = graph.set +local set_effect = graph.set_effect +local capture = graph.capture type Node = graph.Node @@ -38,6 +40,7 @@ type SpringData = { initial_velocity: number, initial_position: T, target_position: T, + target_updated: boolean, target: () -> T } @@ -90,8 +93,9 @@ local springs: { [SpringData]: Node } = {} setmetatable(springs, { __mode = "vs" }) local function spring(target: () -> T, period: number?, damping_ratio: number?): () -> T - local initial_position = target() - local node = create(initial_position) + local inputs, initial_position = capture(target) + + local output, output_get = create(initial_position) local data: SpringData = { alpha = 0, @@ -102,19 +106,28 @@ local function spring(target: () -> T, period: number?, damping_ratio: number initial_velocity = 0, initial_position = initial_position, target_position = initial_position, + target_updated = false, target = target } - springs[data] = node - - return function() - return get(node) + local function input_changed() + data.target_updated = true + data.target_position = target() end + + for _, input in next, inputs do + set_effect(input, input_changed, output) + end + + springs[data] = output + + return output_get end local function update_springs(dt: number) for data, output in next, springs do - if data.target() ~= data.target_position then + if data.target_updated then + data.target_updated = false data.target = data.target() data.initial_position = get(output) data.alpha = 0 @@ -122,9 +135,9 @@ local function update_springs(dt: number) data.initial_velocity = data.velocity end - local initial_position: Animatable = data.initial_position - local target_position: Animatable = data.target_position - local target_type: string = typeof(target_position) + local initial_position = data.initial_position + local target_position = data.target_position + local target_type = typeof(target_position) if target_type ~= typeof(initial_position) then springs[data] = nil diff --git a/test/syntax.luau b/test/syntax.luau deleted file mode 100644 index 19a2abc..0000000 --- a/test/syntax.luau +++ /dev/null @@ -1,43 +0,0 @@ -local vide = require "src/init" -local source = vide.source -local derive = vide.derive -local map = vide.map -local create = vide.create - -type Action = { - type: T, - priority: number, - callback: (Instance) -> () -} - -local function processor(priority: number, fn: (Instance) -> ()): Action - -end - -local function Changed(property: string, callback: () -> ()) - return processor(1, function(instance) - instance:GetPropertyChangedSignal(property):Connect(callback) - end) :: Action<"Changed"> -end - -local function Cleanup(t: {}) - -end - -function TextInput(p: { - OnInput: Action<"Changed"> -}) - create "TextBox" { - Text = "test", - - Changed("Text", function() - - end), - - Cleanup { - - }, - - create("Frame") {} - } -end diff --git a/test/tests.luau b/test/tests.luau index 170f577..b4536ee 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1031,9 +1031,9 @@ TEST("spring()", function() do CASE "Garbage collection" do -- `output` should not allow gc of `input` local input = source(10) - local output = spring(input) + local _output = spring(input) - local wref = { input } + local wref = weak { input } input = nil :: any gc() @@ -1044,7 +1044,7 @@ TEST("spring()", function() local input = source(10) local output = spring(input) - local wref = { output } + local wref = weak { output } output = nil :: any gc() @@ -1099,76 +1099,38 @@ TEST("Events", function() end end) ---[[ -TEST("Changed", function() +TEST("actions", function() local create = vide.create - local Changed = vide.Changed - local source = vide.source + local action = vide.action - do CASE "Connects event" - local connected = false - local label = create "TextLabel" { - [Changed.Text] = function(text) - CHECK(text == "hi") - connected = true - end - } - CHECK(not connected) - label.Text = "hi" - CHECK(connected) - end - - do CASE "Bind connection to state" - local countA = 0 - local countB = 0 - local listener, set = source(function() countA += 1 end :: () -> ()?) - - - local label = create "TextLabel" { - [Changed.Text] = listener - } - - label.Text = "1" - label.Text = "2" - CHECK(countA == 2) - set(function() return function() countB += 1 end end) - label.Text = "3" - label.Text = "4" - CHECK(countA == 2) - CHECK(countB == 2) - set(nil) - label.Text = "5" - CHECK(countA == 2) - CHECK(countB == 2) - end - - do CASE "Always return same object for a given index" - CHECK(Changed.Test == Changed.Test) - end -end) -]] - ---[[ -TEST("Created", function() - local create = vide.create - local Created = vide.Created - - do CASE "Run after instance creation" + do CASE "Run action" local ran = false create "Frame" { - A = true, - B = true, - C = true, - [Created] = function(self) + action(function(self) ran = true - CHECK(self.A and self.B and self.C) - end + end, 1) } CHECK(ran) end -end)]] + + do CASE "Priorities" + local queue = {} + + create "Frame" { + action(function(self) + table.insert(queue, 2) + end, 2), + + action(function(self) + table.insert(queue, 1) + end, 1) + } + + CHECK(testkit.seq(queue, { 1, 2 })) + end +end) TEST("strict", function() vide.strict = true diff --git a/todo.md b/todo.md index d1e704f..203b7d5 100644 --- a/todo.md +++ b/todo.md @@ -1,43 +1,13 @@ -```lua -function TextInput(p: { - DefaultText: string, - Output: (string) -> () -} & Layout) - return create "TextBox" { - Layout = p.Layout, - Children = p.Children +# todo - BackgroundText = DefaultText, - - [{"Changed"}] = function(self) - p.Output(self.Text) - end - } -end - -function Counter() - local count = source(0) - - return create "TextButton" { - Text = count - } -end - -source -derive -map - -spring -``` - -onCleanup -Index -For -untrack -batch -async/loading/suspense - -define order with nested properties +- Implement from solid + - onCleanup + - Index + - For + - untrack + - batch + - async/loading/suspense + - define order with nested properties ```lua type Action = { @@ -50,7 +20,7 @@ local function action(priority: number, fn: (Instance) -> ()): Action end -local function Changed(property: string, callback: () -> ()) +local function changed(property: string, callback: () -> ()) return action(1, function(instance) instance:GetPropertyChangedSignal(property):Connect(callback) end) :: Action<"Changed"> @@ -59,7 +29,7 @@ end create "TextBox" { Text = "test", - Changed "Text" < function(self, data) + changed "Text" < function(self, data) end }