mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
This commit is contained in:
parent
f7f589626c
commit
795ff8725c
13 changed files with 145 additions and 314 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
25
src/action.luau
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
52
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<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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue