This commit is contained in:
Aaron Smith 2023-08-03 11:52:05 +01:00
parent f7f589626c
commit 795ff8725c
13 changed files with 145 additions and 314 deletions

View file

@ -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 allows you to focus on the flow of data through your application without
worrying about manually updating UI instances. 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 ## Creating UI Instances
Instances are created using [`create()`](../api/creation#create). Instances are created using [`create()`](../api/creation#create).

View file

@ -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.

View file

@ -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
```

25
src/action.luau Normal file
View file

@ -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

View file

@ -8,15 +8,30 @@ type Node<T> = graph.Node<T>
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind) 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 for property, value in properties do
if type(value) == "table" then 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 elseif type(property) == "string" then
if type(value) == "function" then if type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value event_buffer[property] = value :: () -> ()
else else
bind.property(instance, property, value :: () -> ()) bind.property(instance, property, value :: () -> ())
end end
@ -37,14 +52,23 @@ local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown
local parent: unknown = properties.Parent local parent: unknown = properties.Parent
if parent then properties.Parent = nil end 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 for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn) (instance :: any)[event]:Connect(fn)
end end
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback()
end
end
if parent then if parent then
if type(parent) == "function" then if type(parent) == "function" then
error("cannot set parent to state") error("cannot set parent to state")

View file

@ -6,13 +6,11 @@ local get = graph.get
local capture_and_link = graph.capture_and_link local capture_and_link = graph.capture_and_link
local function derive<T>(fn: () -> T): () -> T local function derive<T>(fn: () -> T): () -> T
local node = create((nil :: any) :: T) local node, node_get = create((nil :: any) :: T)
node.cache = capture_and_link(node, fn) node.cache = capture_and_link(node, fn)
return function() return node_get
return get(node)
end
end end
return derive return derive

View file

@ -1,7 +1,7 @@
-------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- vide.luau -- vide.luau
-- v0.1.0 -- v0.1.0
-------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end 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 derive = require(script.derive)
local map = require(script.map) local map = require(script.map)
local spring, update_springs = require(script.spring)() local spring, update_springs = require(script.spring)()
local action = require(script.action)()
local flags = require(script.flags) local flags = require(script.flags)
type Map<K, V> = { [K]: V }
type Setter<T> = ( (new: T, force: true?) -> T ) & ( (update: (old: T) -> T, force: true?) -> T )
local vide = { local vide = {
-- core -- core
create = create, create = create,
@ -30,17 +28,13 @@ local vide = {
-- animations -- animations
spring = spring, spring = spring,
-- symbols -- actions
-- Event = Event, action = action,
-- Changed = Changed,
-- Layout = Layout,
-- Children = Children,
-- Created = Created,
-- flags -- flags
strict = (nil :: any) :: boolean, strict = (nil :: any) :: boolean,
-- test -- runtime
step = function(dt: number) step = function(dt: number)
update_springs(dt) update_springs(dt)
clean_garbage() clean_garbage()

View file

@ -49,7 +49,7 @@ local function map<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K)
return recompute(input()) return recompute(input())
end end
local output, get_output = create(output_cache) local output, output_get = create(output_cache)
local nodes, value = capture(input) local nodes, value = capture(input)
@ -59,7 +59,7 @@ local function map<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K)
output.cache = recompute(value) output.cache = recompute(value)
return get_output return output_get
end end
return map return map

View file

@ -3,7 +3,6 @@ if not game then script = require "test/wrap-require" end
local graph = require(script.Parent.graph) local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
local create = graph.create local create = graph.create
local get = graph.get
local set = graph.set local set = graph.set

View file

@ -1,21 +1,21 @@
if not game then script = (require :: any) "test/wrap-require" end if not game then script = (require :: any) "test/wrap-require" end
--[[ --[[
Spring animation library adapted from RDL::spring v1.0
Supported datatypes: Supported datatypes:
*number *number
!bool !bool
*CFrame *CFrame
?Rect ?Rect
*Color3 *Color3
*UDim *UDim
*UDim2 *UDim2
*Vector2 *Vector2
!Vector2int16 !Vector2int16
*Vector3 *Vector3
!Vector3int16 !Vector3int16
!EnumItem !EnumItem
]] ]]
local throw = require(script.Parent.throw) local throw = require(script.Parent.throw)
@ -24,6 +24,8 @@ local graph = require(script.Parent.graph)
local create = graph.create local create = graph.create
local get = graph.get local get = graph.get
local set = graph.set local set = graph.set
local set_effect = graph.set_effect
local capture = graph.capture
type Node<T> = graph.Node<T> type Node<T> = graph.Node<T>
@ -38,6 +40,7 @@ type SpringData<T> = {
initial_velocity: number, initial_velocity: number,
initial_position: T, initial_position: T,
target_position: T, target_position: T,
target_updated: boolean,
target: () -> T target: () -> T
} }
@ -90,8 +93,9 @@ local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" }) setmetatable(springs, { __mode = "vs" })
local function spring<T>(target: () -> T, period: number?, damping_ratio: number?): () -> T local function spring<T>(target: () -> T, period: number?, damping_ratio: number?): () -> T
local initial_position = target() local inputs, initial_position = capture(target)
local node = create(initial_position)
local output, output_get = create(initial_position)
local data: SpringData<T> = { local data: SpringData<T> = {
alpha = 0, alpha = 0,
@ -102,19 +106,28 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
initial_velocity = 0, initial_velocity = 0,
initial_position = initial_position, initial_position = initial_position,
target_position = initial_position, target_position = initial_position,
target_updated = false,
target = target target = target
} }
springs[data] = node local function input_changed()
data.target_updated = true
return function() data.target_position = target()
return get(node)
end end
for _, input in next, inputs do
set_effect(input, input_changed, output)
end
springs[data] = output
return output_get
end end
local function update_springs(dt: number) local function update_springs(dt: number)
for data, output in next, springs do 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.target = data.target()
data.initial_position = get(output) data.initial_position = get(output)
data.alpha = 0 data.alpha = 0
@ -122,9 +135,9 @@ local function update_springs(dt: number)
data.initial_velocity = data.velocity data.initial_velocity = data.velocity
end end
local initial_position: Animatable = data.initial_position local initial_position = data.initial_position
local target_position: Animatable = data.target_position local target_position = data.target_position
local target_type: string = typeof(target_position) local target_type = typeof(target_position)
if target_type ~= typeof(initial_position) then if target_type ~= typeof(initial_position) then
springs[data] = nil springs[data] = nil

View file

@ -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<T> = {
type: T,
priority: number,
callback: (Instance) -> ()
}
local function processor(priority: number, fn: (Instance) -> ()): Action<any>
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

View file

@ -1031,9 +1031,9 @@ TEST("spring()", function()
do CASE "Garbage collection" do CASE "Garbage collection"
do -- `output` should not allow gc of `input` do -- `output` should not allow gc of `input`
local input = source(10) local input = source(10)
local output = spring(input) local _output = spring(input)
local wref = { input } local wref = weak { input }
input = nil :: any input = nil :: any
gc() gc()
@ -1044,7 +1044,7 @@ TEST("spring()", function()
local input = source(10) local input = source(10)
local output = spring(input) local output = spring(input)
local wref = { output } local wref = weak { output }
output = nil :: any output = nil :: any
gc() gc()
@ -1099,76 +1099,38 @@ TEST("Events", function()
end end
end) end)
--[[ TEST("actions", function()
TEST("Changed", function()
local create = vide.create local create = vide.create
local Changed = vide.Changed local action = vide.action
local source = vide.source
do CASE "Connects event" do CASE "Run action"
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"
local ran = false local ran = false
create "Frame" { create "Frame" {
A = true, action(function(self)
B = true,
C = true,
[Created] = function(self)
ran = true ran = true
CHECK(self.A and self.B and self.C) end, 1)
end
} }
CHECK(ran) CHECK(ran)
end 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() TEST("strict", function()
vide.strict = true vide.strict = true

52
todo.md
View file

@ -1,43 +1,13 @@
```lua # todo
function TextInput(p: {
DefaultText: string,
Output: (string) -> ()
} & Layout)
return create "TextBox" {
Layout = p.Layout,
Children = p.Children
BackgroundText = DefaultText, - Implement from solid
- onCleanup
[{"Changed"}] = function(self) - Index
p.Output(self.Text) - For
end - untrack
} - batch
end - async/loading/suspense
- define order with nested properties
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
```lua ```lua
type Action<T> = { type Action<T> = {
@ -50,7 +20,7 @@ local function action(priority: number, fn: (Instance) -> ()): Action
end end
local function Changed(property: string, callback: () -> ()) local function changed(property: string, callback: () -> ())
return action(1, function(instance) return action(1, function(instance)
instance:GetPropertyChangedSignal(property):Connect(callback) instance:GetPropertyChangedSignal(property):Connect(callback)
end) :: Action<"Changed"> end) :: Action<"Changed">
@ -59,7 +29,7 @@ end
create "TextBox" { create "TextBox" {
Text = "test", Text = "test",
Changed "Text" < function(self, data) changed "Text" < function(self, data)
end end
} }