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

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 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<T>(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")

View file

@ -6,13 +6,11 @@ local get = graph.get
local capture_and_link = graph.capture_and_link
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)
return function()
return get(node)
end
return node_get
end
return derive

View file

@ -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> = { [K]: V }
type Setter<T> = ( (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()

View file

@ -49,7 +49,7 @@ local function map<K, VI, VO>(input: () -> Map<K, VI>, 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<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K)
output.cache = recompute(value)
return get_output
return output_get
end
return map

View file

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

View file

@ -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<T> = graph.Node<T>
@ -38,6 +40,7 @@ type SpringData<T> = {
initial_velocity: number,
initial_position: T,
target_position: T,
target_updated: boolean,
target: () -> T
}
@ -90,8 +93,9 @@ local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" })
local function spring<T>(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<T> = {
alpha = 0,
@ -102,19 +106,28 @@ local function spring<T>(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

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

52
todo.md
View file

@ -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<T> = {
@ -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
}