Initial commit

This commit is contained in:
aaron 2023-04-06 02:54:21 +01:00
commit e76234feb5
52 changed files with 5235 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.luau linguist-language=Lua

30
.github/workflows/unit-test.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: unit-test
on:
push:
paths:
- src/**
- test/**
pull_request:
paths:
- src/**
- test/**
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install Luau zip
uses: robinraju/release-downloader@v1.6
with:
repository: Roblox/luau
latest: true
fileName: luau-ubuntu.zip
out-file-path: bin
- name: Unzip Luau
run: unzip bin/luau-ubuntu.zip -d bin
- name: Run unit tests
run: bin/luau test/tests.luau

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
sourcemap.json
.vscode
_local

6
.luaurc Normal file
View file

@ -0,0 +1,6 @@
{
"languageMode": "strict",
"lint": { "BuiltinGlobalWrite": false, "UnknownGlobal": false },
"globals": [ "aa" ]
}

78
CHANGELOG.md Normal file
View file

@ -0,0 +1,78 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
---
## [0.4.0] - 2023-01-26
### Added
- Component grouping and `Registry:group()`.
- Method `View:use()`.
- Constant `ecr.null`.
### Removed
- Method `View:include()` and `Observer:include()`.
### Fixed
- `Registry:add()` not firing `Registry:added()` signals.
- Mismatch between argument list and values returned in multi-typed observers.
- Observers not garbage collecting after calling `Observer:disconnect()`.
### Improved
- Connection firing speed by ~70%.
---
## [0.3.0] - 2023-01-09
### Added
- Overload for `Registry:create()` to create an entity with a given identifier.
### Changed
- Registry signals no longer pass the registry as the first argument to listeners.
- Observers no longer track entities with removed components.
- Method `Registry:entities()` now creates and returns an array of only valid entities.
- Function `ecr.registry()` can no longer pre-allocate memory.
### Removed
- Method `Registry:capacity()`.
### Improved
- Double-type view iteration speed by ~100%.
---
## [0.2.0] - 2022-12-08
### Added
- Method `View:include()` and `Observer:include()`.
- Method `Registry:patch()`.
- Method `Registry:add()` and optional default parameter for `ecr.component()`.
### Changed
- Behavior `for ... in View do` now behaves the same as `for ... in View:each() do`.
- Signal diconnect API (Signal now returns a connection object to call disconnect on).
### Improved
- Entity creation and release speed by ~100%.
- Multi-type view iteration speed by ~60%.
## [0.1.0] - 2022-11-16
- Initial release

21
LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 centau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# Vide
Vide is a declarative, reactive Luau library for building user interfaces on Roblox.
## API Reference
### [Reactivity: Core](docs/api/reactivity-core)
- [wrap()](docs/api/reactivity-core#wrap)
- [derive()](docs/api/reactivity-core#derive)
- [foreach()](docs/api/reactivity-core#wrap)
- [match()](docs/api/reactivity-core#match)
- [watch()](docs/api/reactivity-core#watch)
### [Reactivity: Utility](docs/api/reactivity-utility)
- [isState()](docs/api/reactivity-utility#isState)
- [unwrap()](docs/api/reactivity-utility#unwrap)
- [readonly()](docs/api/reactivity-utility#readonly)
- [mutate()](docs/api/reactivity-utility#mutate)
### [Element Creation](docs/api/creation)
- [create()](docs/api/creation#create)
- [apply()](docs/api/creation#apply)
- [Layout](docs/api/creation#Layout)
- [Children](docs/api/creation#Children)
- [Event](docs/api/creation#Event)
- [Changed](docs/api/creation#Changed)
- [Bind](docs/api/creation#Bind)
- [Created](docs/api/creation#Created)
### [Animation](docs/api/animation)
- [spring()](docs/api/animation#spring)
### [Types](docs/api/types)
- [State<T>](docs/api/types#State)
- [Prop<T>](docs/api/types#Prop)
<br/>
## Tutorials
### [Crash Course](docs/tutorials/crash-course)
### [Reactive Graph](docs/tutorials/reactive-graph)

7
aftman.toml Normal file
View file

@ -0,0 +1,7 @@
# This file lists tools managed by Aftman, a cross-platform toolchain manager.
# For more information, see https://github.com/LPGhatguy/aftman
# To add a new tool, add an entry to this table.
[tools]
rojo = "rojo-rbx/rojo@7.2.1"
# rojo = "rojo-rbx/rojo@6.2.0"

4
default.project.json Normal file
View file

@ -0,0 +1,4 @@
{
"name": "vide",
"tree": { "$path": "src" }
}

56
docs/api/animation.md Normal file
View file

@ -0,0 +1,56 @@
# Animation API
<br/>
## spring()
Returns a new state with an animated value of the original.
### Type
```lua
function spring<T>(state: State<T>, period: number, dampingRatio: number = 1): State<T>
```
### Details
The output state's value is updated every frame based on the current input state's value.
The change is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion).
`period` is the amount of time in seconds it takes for the spring to complete one full cycle
`dampingRatio` is relared to the amount of resistant force applied to the spring.
- \>1 = Overdamped (Not currently supported)
- 1 = Critically damped
- <1 = Underdamped
- 0 = Undamped
Velocity is conserved between input state updates for smooth animation.
### Example
```lua
local state = wrap(1)
local springed = spring(state, 1, 1)
```
<details><summary>Example of an animated counter</summary>
```lua
local count = wrap(1000)
local function Counter(props)
local tweenedCount = spring(count, 0.5, 1)
return create("TextLabel") {
Text = "Count: " .. tweenedCount
}
end
```
</details>
-------------------------------------------------------------------

325
docs/api/creation.md Normal file
View file

@ -0,0 +1,325 @@
# Element Creation API
<br/>
## create()
Creates a new UI element, applying any given properties.
### Type
```lua
function create(classNameOrInstance: string | Instance): (properties: Map<string, any>) -> Instance
```
### Details
The function can take either a `string` or an `Instance` as its first argument.
- If given a `string`, a new instance with the string class name will be created with default properties already applied.
- If given an `Instance`, a new instance that is a clone of the given instance will be created.
This returns another function that is used to apply any properties to the new instance.
### Example
```lua
local frame = create("Frame") {
Name = "NewFrame",
Position = UDim2.fromScale(1, 0)
}
-- creates a clone of `frame` with new properties applied.
local frame2 = create(frame) {
Size = UDim2.fromOffset(50, 100)
}
```
-------------------------------------------------------------------
<br/>
## apply()
Applies any given properties to a given instance.
### Type
```lua
function apply(instance: Instance): (properties: Map<string, any>) -> Instance
```
### Details
Applies properties in the same manner as `create` for already existing existances.
Can use symbols and bind state just like `create`.
### Example
```lua
local frame = Instance.new("Frame")
apply(frame) {
Position = UDim2.fromScale(1, 0)
}
```
-------------------------------------------------------------------
<br/>
## Layout
Symbol used to pass layout properties to elements.
### Type
```lua
type Layout = Symbol
type LayoutProps = {
[Symbol] = {
-- These are all properties considered to be "layout properties"
AnchorPoint: Prop<Vector2>?;
LayoutOrder: Prop<number>?;
Position: Prop<UDim2>?;
Rotation: Prop<number>?;
Size: Prop<UDim2>?;
SizeConstraint: Prop<Enum.SizeConstraint>?;
Visible: Prop<boolean>?;
ZIndex: Prop<number>?;
}
}
type Prop<T> = T | State<T>
```
### Details
The primary purpose of this symbol is to enable easy passthrough of layout properties
through user-defined component hierarchies.
It is recommended to only set layout properties using the `Layout` symbol when using
your own components.
### Example
```lua
local function BlackFrame(props)
return create("Frame") {
BackgroundColor3 = Color3.new(0, 0, 0),
[Layout] = props[Layout]
}
end
BlackFrame {
[Layout] = {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromOffset(100, 50)
}
}
```
-------------------------------------------------------------------
<br/>
## Children
Symbol used to pass child instances to elements.
### Type
```lua
type Children = Symbol
type ChildrenProps = {
[Symbol] = ChildrenProp
}
type ChildrenProp = Prop<Instance> | Array<ChildrenProp>
```
### Details
This symbol is flexible in the way that children can be passed in the form of nested arrays.
Children can also be assigned using a state binding.
### Example
```lua
create("Frame") {
-- all of the below are valid methods of assigning children
[Children] = create("TextLabel") {},
[Children] = {
create("TextLabel") {},
create("TextLabel") {},
},
[Children] = {
create("TextLabel") {},
{
create("TextLabel") {},
}
}
}
```
-------------------------------------------------------------------
<br/>
## Event
Symbol used to connect callbacks to instance events.
### Type
```lua
type Event = Map<string, Symbol>
type EventProps = {
[Symbol] = Prop<(...unknown) -> ()>
}
```
### Details
The `Event` symbol can be indexed to get symbols to connect to specific events.
Event parameters are passed into the callback.
When callbacks are connected by binding to a state,
connections are automatically disconnected when the state changes.
### Example
```lua
create("TextButton") {
[Event.Activated] = function()
print("Clicked")
end
}
```
-------------------------------------------------------------------
<br/>
## Changed
Symbol used to connect callbacks to instance property changed events.
### Type
```lua
type Changed = Map<string, Symbol>
type ChangedProps = {
[Symbol] = Prop<(...unknown) -> ()>
}
```
### Details
The `Changed` symbol can be indexed to get symbols to connect to specific events just like `Event`.
Event parameters are passed into the callback.
When callbacks are connected by binding to a state,
connections are automatically disconnected when the state changes.
### Example
```lua
create("TextBox") {
[Changed.Text] = function()
print("New text entered")
end
}
```
-------------------------------------------------------------------
<br/>
## Bind
Symbol used to bind states to instance properties.
### Type
```lua
type Bind = Map<string, Symbol>
type BindProps = {
[Symbol] = State<any>
}
```
### Details
The `Bind` symbol can be indexed to bind specific properties just like `Event`.
Sets the given state value to the instance property value immediately after instance creation.
When an instance property is changed, the value of the given state will automatically
be set to the new property. Effectively a shorthand for connecting a property changed event
to set state values.
### Example
```lua
local text = wrap()
local box = create("TextBox") {
[Bind.Text] = text
}
box.Text = "New text"
print(text.Value) -- "New text"
```
-------------------------------------------------------------------
<br/>
## Created
Symbol used to run a callback once immediately after instance creation.
### Type
```lua
type Created = Symbol
type CreatedProps = {
[Symbol] = (Instance) -> ()
}
```
### Details
The instance being defined with the `Created` symbol is passed as the first
argument to the callback.
### Example
```lua
local frame
create("Frame") {
Name = "Background",
[Created] = function(instance)
frame = instance
end
}
print(frame.Name) -- "Background"
```
-------------------------------------------------------------------

249
docs/api/reactivity-core.md Normal file
View file

@ -0,0 +1,249 @@
# Reactivity API: Core
<br/>
## wrap()
Wraps and returns any given values with reactive state objects.
### Type
```lua
function wrap<T>(value: T): State<T>
function wrap(value: ...unknown): ...State<any>
type State<T> = {
Value: T,
value: T
}
```
### Details
The state object has a single mutable field `.Value`.
Read operations to `.Value` are tracked and write operations can trigger
dependency updates and watchers.
### Example
```lua
local count = wrap(0)
print(count.Value) -- 0
count.Value += 1
print(count.Value) -- 1
```
-------------------------------------------------------------------
<br/>
## derive()
Derives a new reactive state object from an existing state object.
### Type
```lua
function derive<T>(
(from) -> T,
cleanup: (value: T) -> ()?
): State<T>
function from<T>(T | State<T>): T
```
### Details
The derived state will have its value recalculated when any state it derives from is updated.
Takes a callback that is immediately run to determine what states are being referenced. Only states referenced in the immediate function scope can trigger updates.
The state object returned by this function is readonly.
Has an optional cleanup parameter which takes a function that is called with the old value any
time the derived state recalculates a value.
An optional utility function is passed as the first argument to the callback, if given a state, the value of the state will be returned (changes to this state still triggers updates unlike `unwrap`), if given a value the value is returned.
> ⚠️ The callback cannot yield.
### Example
```lua
local count = wrap(0)
local text = derive(function() return "Count: "..count.Value end)
print(text.Value) -- "Count: 0"
count.Value += 1
print(text.Value) -- "Count: 1"
```
```lua
local count = wrap(0)
local text = derive(function(from)
return "Count: "..from(count)
end)
```
A shorthand method for deriving states also exists, following example is equivalent to the above:
```lua
local count = wrap(0)
local text = "Count: "..count -- all binary operators are supported
```
-------------------------------------------------------------------
<br/>
## foreach()
Derives a new state object from an existing state object.
Designed to work specifically with table states.
Also works with non state tables.
### Type
```lua
function foreach<KO, VO>( -- number as first arg
i: number,
transform: (key: number) -> (KO, VO),
cleanup: (KO, VO) -> ()?
): Map<KO, VO>
function foreach<KI, KO, VI, VO>( -- table as first arg
table: Map<KI, VI>,
transform: (key: KI, value: VI) -> (KO, VO),
cleanup: (KO, VO) -> ()?
): Map<KO, VO>
function foreach<KI, KO, VI, VO>( -- state as first arg
state: State<Map<KI, VI>>,
transform: (key: KI, value: VI) -> (KO, VO),
cleanup: (KO, VO) -> ()?
): State<Map<KO, VO>>
```
### Details
When the state being derived from is updated, the derived state will
recompute by applying its transform function to each key-value pair.
Will only be recomputed if the corresponding key differs between updates.
Has an optional cleanup function to cleanup the old key and value.
> ⚠️ The transform function cannot yield.
### Example
```lua
local numbers = wrap { 1, 2, 3 }
local plusOne = foreach(numbers, function(i, v)
return i, v + 1
end)
print(plusOne.Value) -- { [1]: 2, [2]: 3, [3]: 4 }
-- note that assignment must take place to trigger reactive updates.
-- modifying the value without assignment `numbers.Value[2] = 5` will not trigger updates.
numbers.Value = { 1, 5, 3 }
print(plusOne.Value) -- { [1]: 2, [2]: 6, [3]: 4 }
```
-------------------------------------------------------------------
<br/>
## match()
Derives a new state object from an existing state object.
Similar to switch statements in other languages.
### Type
```lua
function match<K, V>(value: K): (transform: Map<K, V>) -> V
function Match<K, V>(state: State<K>): (transform: Map<K, V>) -> State<V>
```
### Details
When the state being derived from is updated, the derived state will
recompute by using the input value as a key to map to an output value.
### Example
```lua
local state = wrap(true)
local matched = match(state) {
[true] = 1,
[false] = 0
}
print(matched.Value) -- 1
state.Value = false
print(matched.Value) -- 0
```
-------------------------------------------------------------------
<br/>
## watch()
Runs a callback on state change.
### Type
```lua
function watch(callback: () -> Cleanup?): Unwatch
type Cleanup = () -> ()
type Unwatch = () -> ()
```
### Details
The callback is ran immediately to determine what states to watch.
Any time a state read in the watch callback is changed, the watcher callback will be deferred
to the end of the resumption cycle and ran.
Only states in the immediate function scope can trigger the watch callback.
Watchers are run *before* UI properties are updated.
The callback can return an optional cleanup function that is run each time the watcher is rerun.
Also returns a function that when called, stops the watcher immediately (also runs cleanup if any was given).
> ⚠️ The callback cannot yield.
### Example
```lua
local state = wrap(1)
watch(function()
print(state.Value)
end)
-- prints 1
state.Value += 1
-- prints 2
```
-------------------------------------------------------------------

View file

@ -0,0 +1,152 @@
# Reactivity API: Utility
<br/>
## isState()
Determines if a given value is a state object or not.
### Type
```lua
function isState(value: unknown): boolean
```
### Example
```lua
local value = wrap()
print(isState(value)) -- true
value = 0
print(isState(value)) -- false
```
-------------------------------------------------------------------
<br/>
## unwrap()
Unwraps a state and returns its stored value.
### Type
```lua
function unwrap<T>(value: T | State<T>): T
```
### Details
If given a state, the state's stored value will be returned.
Unwrapping a state within a derived callback will not trigger updates.
Can be given a non-state value, in which case the same value will just be returned.
### Example
```lua
local state = wrap(1)
print(unwrap(state)) -- 1
print(unwrap(1)) -- 1
```
-------------------------------------------------------------------
<br/>
## readonly()
Creates a new derived state with the same value as the state being derived from.
Used to create readonly states.
### Type
```lua
function readonly<T>(state: State<T>): State<T>
```
### Example
```lua
local count = wrap(1)
local read = readonly(count)
print(read.Value) -- 1
count.Value += 1
print(read.Value) -- 2
read.Value += 1 -- error
```
-------------------------------------------------------------------
<br/>
## mutate()
Mutates a given state's value and updated any derived states.
### Type
```lua
function mutate<T>(value: T | State<T>): T
```
### Details
Since states only update derived states if a new value is set (tables are compared by reference),
this function serves as a way to trigger derived state updates if a state's value is not changed but
instead mutated.
Can also take non-state as an argument.
### Example
```lua
local state = wrap { Count = 1 }
local derived = derive(function()
return state.Value.Count
end)
mutate(state, function(value)
value.Count += 1
end)
print(derived.Value) -- 2
```
<details><summary>Motivation for this function</summary>
```lua
local state = wrap { Count = 1 }
local derived = derive(function()
return state.Value.Count
end)
state.Value.Count += 1
print(derived.Value) -- still 1 because `state.Value` was never set with a new value so change wasn't detected
local value = state.Value
value.Count += 1
state.Value = value
print(derived.Value) -- still 1 because although `state.Value` was set, when the new value set was compared,
-- it was still the same as the previous (tables are compared by reference not their contents)
-- and so no update was made
```
</details>
-------------------------------------------------------------------

57
docs/api/strict.md Normal file
View file

@ -0,0 +1,57 @@
# Strict Mode
<br/>
## strict
A flag that users can set to enable or disable strict mode (disabled by default).
### Type
```ts
boolean strict = false
```
### Details
The purpose of strict mode is to help ensure stateful code is *pure* (deterministic and free from side effects).
Setting this flag is global for all scripts requiring the same instance of the Vide module.
What strict mode will do:
1. Run derived callbacks twice when calculating state value.
2. Run watcher callbacks twice each time state changes.
3. Throw an error if a derived callback yields.
4. Throw an error if a watcher callback yields.
It is recommend to develop UI with strict mode set to `true`
and to set it back to false when pushing to production.
Using strict mode will help identify potential non-deterministic code and side-effects by running code
multiple times in places where it would only run once.
Strict mode will also ensure that watcher side effects are self contained in the sense that they clean themselves up
properly when ran multiple times in quick succession, in case any asynchronous operation is performed.
Yielding within derived or watcher callbacks can cause undefined behavior as the reactive graph is not designed
to work with asynchronous code. Strict mode can identify and throw an error when asynchronous code is detected.
This isn't done during runtime as these checks are computationally expensive.
### Example
```lua
vide.strict = true -- this only needs to be done once, preferably in the first module to require Vide
local state = wrap()
watch(function()
local cleanup = doAsyncOperation(state.Value)
return function()
cleanup()
end
end)
state.Value = 1 -- this will cause the watcher to be ran twice, identifying if cleanup occurs properly
```

55
docs/api/types.md Normal file
View file

@ -0,0 +1,55 @@
# Types API
<br/>
## State\<T>
A type representing a Vide state object.
### Type
```lua
type State<T> = {
Value: T,
value: T
}
```
### Example
```lua
local state: State<number> = wrap(1)
local derived: State<string> = "Count: " .. state
```
-------------------------------------------------------------------
<br/>
## Prop\<T>
A utility type representing a union of a value and a state.
### Type
```lua
type Prop<T> = T | State<T>
```
### Example
```lua
type BackgroundProps = {
Position: Prop<UDim2>,
Size: Prop<UDim2>
}
local function Background(props: { Position: Prop<UDim2> })
return create("Frame") {
Position = props.Position
Size = props.Size
}
end
```
-------------------------------------------------------------------

337
docs/tut/crash-course.md Normal file
View file

@ -0,0 +1,337 @@
# Vide Crash Course
Hello! This is a brief tutorial designed to give you a quick runthrough of the usage of Vide.
Vide is inspired by the popular libraries Vue and Fusion.
- Note that this tutorial assumes that you are familiar with Luau and the Roblox UI system.
<br>
## Creating UI Instances
In Vide, it is intended to create all UI instances through code.
Instances are created using [`vide.create`](../api/creation#create).
```lua
local vide = require(...)
local create = vide.create
```
```lua
local frame = create("Frame") {
Name = "Background",
Position = UDim2.fromScale(0.5, 0.5)
}
```
The function returns a constructor for a given class which then takes a table of properties to assign to create a new instance for that class.
Sometimes you want to do more than setting properties, such as setting children or connecting to events.
Vide uses special keys called *symbols* which provide unique functionality like the above mentioned.
Children can be assigned to instances using the `Children` symbol.
```lua
local Children = vide.Children
```
```lua
local screenGui = create("ScreenGui") {
Parent = game.StarterGui,
[Children] = create("Frame") {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromScale(0.4, 0.7),
[Children] = {
create("TextLabel") {
Text = "Hi"
},
create("TextLabel") {
Text = "Bye"
}
}
}
}
```
Here, we import the symbol [`vide.Children`](../api/creation#Children).
This symbol can accept an instance, an array of instances and nested arrays of instances.
All given instances will be parented to the instance the symbol was used on.
<br>
## Connecting To Events
Built-in instance events and property changed events can be connected to using two other symbols, [`vide.Event`](../api/creation#Event) and [`vide.Changed`](../api/creation#Changed).
```lua
local Event = vide.Event
local Changed = vide.Changed
```
```lua
local textBox = create("TextBox") {
PlaceholderText = "Enter text",
[Event.Focused] = function(...)
print("User is focusing on text box")
end,
[Changed.Text] = function(newText)
print("New text: " .. newText)
end
}
```
Both of these symbols can be indexed into to get a specific symbol for an event to connect to.
The callback function for `Event` receives any event-specific arguments and the callback function for
`Changed` receives the new property value as the only argument (unlike `Instance:GetPropertyChangedSignal()`).
<br>
## State
*State* is the condition something is in at a specific time. The state of a program is simply the data it contains at some timepoint.
The purpose of all UI is to take some state and reflect that state visually.
In Vide, UI state is represented using special objects simply called *state*.
A state object in Vide can be created using [`vide.wrap`](../api/reactivity-core#wrap).
```lua
local wrap = vide.wrap
```
```lua
local isVisible = wrap(false)
local image = create("ImageLabel") {
Image = "rbxassetid://xxx",
Visible = isVisible
}
while true do
wait(1)
isVisible.Value = not isVisible.Value
end
```
The function `wrap` will *wrap* any given value with a state object of type `State<T>` which can be read from/wrote to through its `.value` property.
In the above code, the `ImageLabel.Visible` property is assigned a state. Now any time that state's value is assigned to, `ImageLabel.Visible` will also update with the new value assigned, without you having to explicitly set the property. The above code gives the effect of the image label toggling visibility at a 1 second interval forever.
There are a few reasons why we use state objects instead of plain variables:
1. Vide detects when you assign a state object as a property value. This is known as *binding* and doing so will cause the property to *automatically* update whenever that state object's value is changed.
2. We can create new state objects that derive from other state objects, which again, *automatically* update when the derived state objects change.
The reason why this is useful, is that you as the programmer do not have to worry about manually updating variables or UI instances, you can just focus on defining how the data maps to UI and everything will automatically update when changes occur.
<br>
## Derived State
You can create new state from other states. This is known as *deriving state*.
```lua
local derive = vide.derive
```
```lua
local count = wrap(0)
local text = derive(function(from)
return "Count: " .. from(count)
end)
print(text.value) -- "Count: 0"
count.value += 1
print(text.value) -- "Count: 1"
```
Here we use [`vide.derive`](../api/reactivity-core#derive) to *derive* a new state `text` which depends on `count`.
A function is used to transform the value of `count`, where the value returned becomes the new value of `text`. The function receives an argument named `from` which is used to *capture* dependent states. This is used to link `count` to `text`, so that whenever `count` is updated, `text` will be too.
Whenever `count`'s value is changed, `text` will recompute its value and update anything dependent on `text`, such as UI.
States can be derived in a more concise manner when doing single operations such as concatenation:
```lua
local text = "Count: " .. count
```
You can derive new states using any Luau operator in this manner.
<br>
## Components
*Components* in UI are just custom-made reusable pieces of UI made from other pieces of UI.
The recommended way to create components is to use functions that take a table of properties as an argument and return the new UI instance.
```lua
local function Background(args)
return create("Frame") {
BackgroundColor3 = Color3.new(0, 0, 0),
Position = args.Position,
Size = args.Size
}
end
local background = Background {
Position = UDim2.new(),
Size = UDim2.new()
}
```
Above is a simple example of a frame component with its background color set to black.
A single parameter `args` is used to pass properties to the component.
Components allow you to *encapsulate* behavior. You can only modify the component in ways that are defined in the component.
Looking at the above example, the only properties you are allowed to modify is `Position` and `Size`.
This is a good approach to use for organised code.
However, properties concering layout (positional and size properties) aren't usually intrinsinc to the component. In most cases the user would want to be able to pass these properties without having to manually pass each one in the component.
For these cases, the [`vide.Layout`](../api/creation#Layout) symbol can be used.
```lua
local Layout = vide.Layout
```
```lua
local function Background(props)
return create("Frame") {
BackgroundColor3 = Color3.new(0, 0, 0),
[Layout] = props[Layout],
[Children] = props[Children]
}
end
local background = Background {
[Layout] = {
AnchorPoint = Vector2.new(),
Position = UDim2.new(),
Size = UDim2.new(),
},
[Children] = {
create("TextLabel") {},
create("ImageLabel") {}
}
}
```
Here, the `Layout` symbol automatically assigns those layout-specific properties without having to explicitly assign each one in the component definition. This is a very common case and for this reason it is recommended to only assign layout properties using the `Layout` symbol for consistency when dealing with components.
This allows you to pass through layout properties without breaking encapsulation.
Additionally, the above example shows how children can be passed to components in a similar manner.
<br>
## Stateful components
Often, you need components that maintain their own internal state, such as a toggle button or a counter.
Below you can see how a simple counter component can be implemented.
```lua
local function Counter(args)
-- create internal state unique to each component instance
local count = wrap(0)
return create("TextButton") {
Name = "Counter",
Text = "Count: " .. count
[Event.Activated] = function()
count.value += 1
end
[Layout] = args[Layout]
}
end
create "ScreenGui" {
Parent = game.StarterGui,
[Children] = {
Counter {
[Layout] = {
AnchorPoint = Vector2.new(0.5, 0),
Position = UDim2.fromScale(0.5, 0),
Size = UDim2.fromScale(0.3, 0.1)
}
}
}
}
```
Here a reusable counter component is created, that when clicked on will increase its count and display it independent from other counter instances.
## Tables of data
basic inventory
```lua
type Item = {
Name: string,
Icon: number
}
local items = wrap({} :: Array<Item>)
local function ItemSlot(args)
return create("Frame") {
[Layout] = args[Layout]
[Children] = {
create("TextLabel") {
Name = args.Name,
[Layout] = ...
},
create("ImageLabel") {
Image = "rbxassetid://" .. args.Icon,
[Layout] = ...
}
}
}
end
local function Inventory(args)
return create("Frame") {
[Layout] = args[Layout],
[Children] = {
create("UIListLayout") {},
map(items, function(i, item)
return ItemSlot {
Name = item.Name,
Icon = item.Icon,
[Layout] = { LayoutOrder = i, ... }
}
end)
}
}
end
More comprehensive tutorials are in the works. To find out more refer to the [`API documentation`](../../README#API).

View file

@ -0,0 +1,99 @@
# 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.

20
docs/tut/tmp.md Normal file
View file

@ -0,0 +1,20 @@
```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
```

55
src/Change.lua Normal file
View file

@ -0,0 +1,55 @@
------------------------------------------------------------------------------------------
-- vide/Change.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local memoize = require(script.Parent.memoize)
local bind = require(script.Parent.bind)
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create
local get = graph.get
local link = graph.link
local wrapped = graph.wrapped
local Types = require(script.Parent.Types)
type Listener = (unknown) -> ()
local getChangeSymbol = memoize(function(name: string): Types.Symbol<MaybeState<Listener>>
return {
priority = 2,
run = function(instance: Instance, listener: MaybeState<Listener>)
local event: RBXScriptSignal<nil> = instance:GetPropertyChangedSignal(name)
if type(listener) == "function" then
event:Connect(function()
listener( (instance :: any)[name] )
end)
elseif wrapped(listener) then
local state = create(nil)
link(listener :: State<Listener>, state, function()
local newListener = get(listener :: State<Listener>)
return newListener and function()
newListener( (instance :: any)[name] )
end
end)
state.updated = true
bind.event(state :: State<any>, instance, event)
else
error("Attempt to connect non-function to changed event", 2)
end
end
}
end)
local Changed = table.freeze(setmetatable({}, {__index = function(_, index: string)
return getChangeSymbol(index)
end})) :: any
return Changed :: { [string]: unknown }

39
src/Children.lua Normal file
View file

@ -0,0 +1,39 @@
------------------------------------------------------------------------------------------
-- vide/Children.lua
------------------------------------------------------------------------------------------
if not game then
script = (require :: any) "test/wrap-require"
typeof = require "test/mock".typeof
end
local graph = require(script.Parent.graph)
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind)
local Types = require(script.Parent.Types)
type Children = Types.Children
local function setChildren(instance: Instance, children: Children)
if typeof(children) == "Instance" then
if children.Parent then throw(`Cannot parent instance { instance.Name }, instance already parented`) end
children.Parent = instance
elseif wrapped(children) then
bind.children(children :: any, instance )
elseif type(children) == "table" then
for _, child: Children in next, children do
setChildren(instance, child)
end
else
throw(`Cannot parent non-instance { typeof(children) }`)
end
end
local Children = {
priority = 1,
run = setChildren
} :: Types.Symbol<Children>
return Children :: unknown

16
src/Created.lua Normal file
View file

@ -0,0 +1,16 @@
------------------------------------------------------------------------------------------
-- vide/Created.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local Types = require(script.Parent.Types)
local Created = {
priority = 3,
run = function(instance: Instance, callback: (Instance) -> ())
callback(instance)
end
} :: Types.Symbol<(Instance) -> ()>
return Created :: unknown

41
src/Event.lua Normal file
View file

@ -0,0 +1,41 @@
------------------------------------------------------------------------------------------
-- vide/Event.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local memoize = require(script.Parent.memoize)
local bind = require(script.Parent.bind)
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local wrapped = graph.wrapped
local Types = require(script.Parent.Types)
type Listener = (unknown) -> ()
local getEventSymbol = memoize(function(name: string): Types.Symbol<MaybeState<Listener>>
return {
priority = 2,
run = function(instance: Instance, listener: MaybeState<Listener>)
local event: RBXScriptSignal<...unknown> = (instance :: any)[name]
if type(listener) == "function" then
event:Connect(listener)
elseif wrapped(listener) then
bind.event(listener :: State<Listener>, instance, event)
else
error("Attempt to connect non-function to event", 2)
end
end
}
end)
local Event = table.freeze(setmetatable({}, {__index = function(_, index: string)
return getEventSymbol(index)
end})) :: any
return Event :: { [string]: unknown }

46
src/Layout.lua Normal file
View file

@ -0,0 +1,46 @@
------------------------------------------------------------------------------------------
-- vide/Layout.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind)
local flags = require(script.Parent.flags)
local Types = require(script.Parent.Types)
local layoutProperties = {
Parent = true,
AnchorPoint = true,
LayoutOrder = true,
Position = true,
Rotation = true,
Size = true,
SizeConstraint = true,
Visible = true,
ZIndex = true
}
local function setLayout(instance: Instance, properties: { [string]: unknown })
for property, value in next, properties do
if flags.strict and not layoutProperties[property] then
throw(`{ property } is not a valid layout property`)
end
if wrapped(value) then
bind.property(value :: graph.State<unknown>, instance, property)
else
(instance :: any)[property] = value
end
end
end
local Layout = {
priority = 1,
run = setLayout
} :: Types.Symbol<{ [string]: unknown }>
return Layout :: unknown

19
src/Types.lua Normal file
View file

@ -0,0 +1,19 @@
--------------------------------------------------------------------------------------------------------------
-- vide/Types.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
export type Symbol<T> = {
priority: number,
run: (Instance, T) -> ()
}
export type Children = Instance | State<Children> | { Children }
return {}

15
src/apply.lua Normal file
View file

@ -0,0 +1,15 @@
------------------------------------------------------------------------------------------
-- vide/apply.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local applyProperties = require(script.Parent.applyProperties)
local function apply<T>(instance: T & Instance)
return function(properties: { [any]: unknown }): T
return applyProperties(instance, properties)
end
end
return apply

66
src/applyProperties.lua Normal file
View file

@ -0,0 +1,66 @@
------------------------------------------------------------------------------------------
-- vide/applyProperties.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind)
local Types = require(script.Parent.Types)
local function applyProperty(instance: Instance, property: string, value: unknown)
end
local function applyProperties<T>(instance: T & Instance, properties: { [string|Types.Symbol<unknown> ]: unknown }): T
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
local eventBuffer: { [(Instance, () -> ()) -> ()]: () -> () } = {} -- connect events after setting properties
local postCreation: (Instance) -> ()? = nil -- buffer for post-creation callback
for property, value in next, properties do
if type(property) == "string" then
if wrapped(value) then
bind.property(value :: State<unknown>, instance, property)
else
(instance :: any)[property] = value
end
elseif type(property) == "table" then
local priority = property.priority
if priority == 1 then
property.run(instance, value)
elseif priority == 2 then
eventBuffer[property.run] = value :: () -> ()
elseif priority == 3 then
assert(not postCreation)
postCreation = value :: () -> ()
else
error("invalid priority")
end
else throw(`Invalid property { tostring(property) }, expected string or symbol`) end
end
for fn, v in next, eventBuffer do
fn(instance, v)
end
if parent then
applyProperty(instance, "Parent", parent)
if wrapped(parent) then
bind.parent(parent :: State<Instance?>, instance)
else
instance.Parent = parent :: Instance
end
end
if postCreation then postCreation(instance) end
return instance
end
return applyProperties

139
src/bind.lua Normal file
View file

@ -0,0 +1,139 @@
--------------------------------------------------------------------------------------------------------------
-- vide/bind.lua
--------------------------------------------------------------------------------------------------------------
local warn = warn -- todo
if not game then
script = (require :: any) "test/wrap-require"
warn = print
end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
local get = graph.get
local setEffect = graph.setEffect
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
local hold: { Instance? } = {}
local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any
local bindcount = 0
local srcs do
local src1 = debug.info(1, "s")
local srctrunc = string.sub(src1, 1, #src1-4)
srcs = {
src1,
srctrunc .. "applyProperties",
srctrunc .. "create",
srctrunc .. "apply"
}
end
local function traceback() -- ensures trace begins outside of any vide library file
local s = 1
repeat
s += 1
local src = debug.info(s, "s")
until not table.find(srcs, src)
return debug.traceback("", s)
end
function setup(state: State<any>, instance: Instance, updateInstance: (Instance) -> ())
if flags.strict then
local fn = updateInstance
local trace = traceback()
updateInstance = function(instance)
local ok, err: string? = pcall(fn, instance)
if not ok then warn(`error occured updating state binding:\n{err}\nset from:{trace}`) end
end
end
updateInstance(instance)
setEffect(state, updateInstance, instance)
bindcount += 1
local key = bindcount
weak[key] = instance
local function ref()
local _ = state -- prevent gc of state while instance exists
local instance = weak[key] :: Instance
hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented
end
ref()
instance:GetPropertyChangedSignal("Parent"):Connect(ref)
end
local function bindProperty(state: State<unknown>, instance_STRONG: Instance, property: string)
setup(state, instance_STRONG, function(instance)
(instance :: any)[property] = get(state)
end)
end
local function bindParent(state: State<Instance?>, instance_STRONG)
instance_STRONG.Destroying:Connect(function()
instance_STRONG = nil :: any -- allow gc when destroyed
end)
setup(state, instance_STRONG, function(instance)
local _ = instance_STRONG -- state will strongly reference instance when parent is bound
instance.Parent = get(state)
end)
end
local function bindChildren(state: State<{ Instance }?>, parent_STRONG: Instance)
local currentChildrenSet: { [Instance]: true } = {} -- cache of all children parented before update
local newChildrenSet: { [Instance]: true } = {} -- cache of all children parented after update
setup(state, parent_STRONG, function(parent)
local newChildren = get(state) -- all (and only) children that should be parented after this update
if newChildren and type(newChildren) ~= "table" then
throw(`Cannot parent instance of type { type(newChildren) } `)
end
if newChildren then
for _, child in next, newChildren do
newChildrenSet[child] = true -- record child set from this update
if not currentChildrenSet[child] then
child.Parent = parent -- if child wasn't already parented then parent it
else
currentChildrenSet[child] = nil -- remove child from cache if it was already in cache
end
end
end
for child in next, currentChildrenSet do
child.Parent = nil -- unparent all children that weren't in the new children set
end
table.clear(currentChildrenSet) -- clear cache, preserve capacity
currentChildrenSet, newChildrenSet = newChildrenSet, currentChildrenSet
end)
end
local function bindEvent(state: State<() -> ()?>, instance_STRONG: Instance, event: RBXScriptSignal)
local current: RBXScriptConnection? = nil
setup(state, instance_STRONG, function(instance)
if current then
current:Disconnect()
current = nil
end
local listener = get(state)
if listener then
current = event:Connect(listener)
end
end)
end
return {
property = bindProperty,
parent = bindParent,
children = bindChildren,
event = bindEvent
}

78
src/create.lua Normal file
View file

@ -0,0 +1,78 @@
--------------------------------------------------------------------------------------------------------------
-- vide/create.lua
--------------------------------------------------------------------------------------------------------------
if not game then
script = (require :: any) "test/wrap-require"
Instance = require("test/mock").Instance
end
local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults)
local applyProperties = require(script.Parent.applyProperties)
local memoize = require(script.Parent.memoize)
local function createInstance(className: string)
local success, instance: Instance = pcall(Instance.new, className :: any)
if success == false then throw(`invalid class name, could not create instance of class { className }`) end
local default: { [string]: unknown }? = defaults[className]
if default then
for i, v in next, default do
(instance :: any)[i] = v
end
end
return function(properties: { [any]: unknown }): Instance
return applyProperties(instance:Clone(), properties)
end
end; createInstance = memoize(createInstance)
local function cloneInstance(instance: Instance)
return function(properties: { [any]: unknown }): Instance
local clone = instance:Clone()
if not clone then error("Attempt to clone a non-archivable instance", 3) end
return applyProperties(clone, properties)
end
end
local function create(classNameOrInstance: string|Instance)
if type(classNameOrInstance) == "string" then
return createInstance(classNameOrInstance)
elseif typeof(classNameOrInstance) == "Instance" then
return cloneInstance(classNameOrInstance)
else
error("Bad argument #1, expected string or instance, got "..typeof(classNameOrInstance), 2)
end
end
type Props = { [any]: any }
return (create :: any) ::
( <T>(T & Instance) -> (Props) -> T ) &
( ("Folder") -> (Props) -> Folder ) &
( ("BillboardGui") -> (Props) -> BillboardGui ) &
( ("CanvasGroup") -> (Props) -> CanvasGroup ) &
( ("Frame") -> (Props) -> Frame ) &
( ("ImageButton") -> (Props) -> ImageButton ) &
( ("ImageLabel") -> (Props) -> ImageLabel ) &
( ("ScreenGui") -> (Props) -> ScreenGui ) &
( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) &
( ("SurfaceGui") -> (Props) -> SurfaceGui ) &
( ("TextBox") -> (Props) -> TextBox ) &
( ("TextButton") -> (Props) -> TextButton ) &
( ("TextLabel") -> (Props) -> TextLabel ) &
( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) &
( ("UICorner") -> (Props) -> UICorner ) &
( ("UIGradient") -> (Props) -> UIGradient ) &
( ("UIGridLayout") -> (Props) -> UIGridLayout ) &
( ("UIListLayout") -> (Props) -> UIListLayout ) &
( ("UIPadding") -> (Props) -> UIPadding ) &
( ("UIPageLayout") -> (Props) -> UIPageLayout ) &
( ("UIScale") -> (Props) -> UIScale ) &
( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) &
( ("UIStroke") -> (Props) -> UIStroke ) &
( ("UITableLayout") -> (Props) -> UITableLayout ) &
( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) &
( ("VideoFrame") -> (Props) -> VideoFrame ) &
( ("ViewportFrame") -> (Props) -> ViewportFrame ) &
( (string) -> (Props) -> Instance )

112
src/defaults.lua Normal file
View file

@ -0,0 +1,112 @@
--------------------------------------------------------------------------------------------------------------
-- vide/defaults.lua
--------------------------------------------------------------------------------------------------------------
-- todo
local Enum = Enum
local Color3 = Color3
local Vector3 = Vector3
if not game then
local mock = require "test/mock"
Enum = mock.Enum :: any
Color3 = mock.Color3 :: any
Vector3 = mock.Vector3 :: any
end
return {
Part = {
Material = Enum.Material.SmoothPlastic,
Size = Vector3.new(1, 1, 1),
Anchored = true
},
BillboardGui = {
ResetOnSpawn = false,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling
},
CanvasGroup = nil,
Frame = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0
},
ImageButton = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
AutoButtonColor = false
},
ImageLabel = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
},
ScreenGui = {
ResetOnSpawn = false,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling
},
ScrollingFrame = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
ScrollBarImageColor3 = Color3.new(0, 0, 0)
},
SurfaceGui = {
ResetOnSpawn = false,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
PixelsPerStud = 50,
SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud
},
TextBox = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
ClearTextOnFocus = false,
Font = Enum.Font.SourceSans,
Text = "",
TextColor3 = Color3.new(0, 0, 0)
},
TextButton = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
AutoButtonColor = false,
Font = Enum.Font.SourceSans,
Text = "",
TextColor3 = Color3.new(0, 0, 0)
},
TextLabel = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
Text = "",
TextColor3 = Color3.new(0, 0, 0)
},
-- UIComponent instances
VideoFrame = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0
},
ViewportFrame = {
BackgroundColor3 = Color3.new(1, 1, 1),
BorderColor3 = Color3.new(0, 0, 0),
BorderSizePixel = 0
}
}

32
src/derive.lua Normal file
View file

@ -0,0 +1,32 @@
------------------------------------------------------------------------------------------
-- vide/derive.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type Unwrapper = graph.Unwrapper
local create = graph.create
local captureAndLink = graph.captureAndLink
local function derive<T>(deriveValue: (Unwrapper) -> T, cleanup: (T) -> ()?): State<T>
local node = create((nil :: any) :: T)
if cleanup then
local fn = deriveValue
local last: T? = nil
deriveValue = function(from)
if last ~= nil then cleanup(last) end
last = fn(from)
return last :: T
end
end
local value: T = captureAndLink(node, deriveValue)
node.cache = value
return node :: State<T>
end
return derive

24
src/each.lua Normal file
View file

@ -0,0 +1,24 @@
--------------------------------------------------------------------------------------------------------------
-- vide/each.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type Node<T> = graph.Node<T>
local create = graph.create
local get = graph.get
local wrapped = graph.wrapped
local link = graph.link
type Map<K, V> = { [K]: V }
local function each<K, VI, VO>(input: State<Map<K, VI>>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?)
end
return (each :: any) :: ( <V>(input: number, transform: (number) -> V) -> Map<number, V> ) &
( <K, VI, VO>(input: Map<K, VI>, transform: (K, VI) -> VO) -> Map<K, VO> ) &
( <K, VI, VO>(input: State<Map<K, VI>>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?)
-> State<Map<K, VO>> )

5
src/flags.lua Normal file
View file

@ -0,0 +1,5 @@
--------------------------------------------------------------------------------------------------------------------------------
-- vide/flags.lua
--------------------------------------------------------------------------------------------------------------------------------
return { strict = false }

231
src/graph.lua Normal file
View file

@ -0,0 +1,231 @@
--------------------------------------------------------------------------------------------------------------
-- vide/graph.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local flags = require(script.Parent.flags)
export type State<T> = typeof(setmetatable(
{} :: {
cache: T,
updated: boolean,
derive: (any) -> T,
effects: { [(unknown) -> ()]: unknown } | false, -- weak values
children: { State<T> } | false -- weak values
}, {} :: {
__concat: (any, any) -> any,
__add: (any, any) -> any,
__sub: (any, any) -> any,
__mul: (any, any) -> any,
__div: (any, any) -> any,
--__pow
--__mod
--__unm
--__eq: (unknown, unknown) -> State<boolean>
--__lt
--__le
}
))
export type MaybeState<T> = State<T> | T
export type Unwrapper = <T>(T) -> T
local WEAK_VALUES_RESIZABLE = { __mode = "vs" }
local EVALUATION_ERR = "Error while evaluating state:\n\n"
local State = {}
local function wrapped(value: any): boolean
return getmetatable(value) == State
end
local unwrap: <T>(T) -> T;
local checkForYield do
local t = { __mode = "kv" }
setmetatable(t, t)
checkForYield = function(fn: (Unwrapper) -> ())
t.__unm = function()
fn(unwrap)
end
local ok, err = pcall(function()
return -t :: any
end)
if not ok then
if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then
error(EVALUATION_ERR .. "Cannot yield when deriving state in watcher", 3)
else
error(EVALUATION_ERR..err, 3)
end
end
end
end
local function setEffect<T>(state: State<unknown>, fn: (T) -> (), key: T)
if not state.effects then
state.effects = setmetatable({ [fn] = key }, WEAK_VALUES_RESIZABLE) :: any
else
state.effects[fn :: () -> ()] = key
end
end
local function runEffects(state: State<unknown>)
if state.effects then
for effect, key in next, state.effects do
if flags.strict then effect(key) end
effect(key)
end
end
end
-- retrieves a state's cached value
-- recalculates value if an ancestor was updated
local function get<T>(state: State<T>): T
if state.updated then
state.updated = false
if flags.strict then checkForYield(state.derive) end
local ok, result: T|string? = pcall(state.derive, unwrap); if ok then
rawset(state :: any, "cache", result :: T)
else error(EVALUATION_ERR .. result :: string, 2) end
end
return state.cache
end
-- utility function for retrieving a value from state and allowing passthrough of non-state
unwrap = function<T>(value: MaybeState<T>): T
if wrapped(value) then
return get(value :: State<T>)
else
return value :: T
end
end
local function addChild(parent: State<unknown>, child: State<unknown>)
if parent.children then
table.insert(parent.children, child)
else
parent.children = setmetatable({ child }, WEAK_VALUES_RESIZABLE) :: any
end
end
-- marks all state descendants for recalculation and runs effects
local function update(state: State<unknown>)
runEffects(state)
if state.children then
for _, child in state.children do
if not child.updated then
child.updated = true
update(child)
end
end
end
end
-- sets a state's cached value and updates all descendants
local function set<T>(state: State<T>, value: T)
state.cache = value
update(state)
end
-- links two states as parent-child
local function link(parent: State<unknown>, child: State<unknown>, derive: () -> unknown)
child.derive = derive
addChild(parent, child)
end
-- detect what states were referenced in the given callback and returns them in an array
local function capture<T>(callback: (Unwrapper) -> T): ({ State<unknown> }, T)
if flags.strict then checkForYield(callback) end
local states = table.create(2)
local ok: boolean, result: T|string = pcall(callback, function<T>(value: MaybeState<T>): T
if wrapped(value) then
table.insert(states, value :: State<T>)
return get(value :: State<T>)
else
return value :: T
end
end)
if not ok then error("Error while detecting watcher: " .. result :: string, 2) end
return states, result :: T
end
-- captures and links any detected states
local function captureAndLink<T>(child: State<T>, callback: (Unwrapper) -> T): T
local states, value = capture(callback)
child.derive = callback
for _, parent: State<unknown> in next, states do
addChild(parent, child)
end
return value :: T
end
local create: <T>(value: T) -> State<T>
-- factory function for creating operator overloads for shorthands to derive state
local function overload(op: (unknown, unknown) -> unknown): (any, any) -> any
return function(a: MaybeState<unknown>, b: MaybeState<unknown>): State<unknown>
local derived: State<unknown> = create(nil :: any)
local aIsState = wrapped(a)
local bIsState = wrapped(b)
if aIsState and bIsState then
local function derive() return op(get(a :: State<unknown>), get(b :: State<unknown>)) end
link(a :: State<unknown>, derived, derive)
link(b :: State<unknown>, derived, derive)
elseif aIsState then
link(a :: State<unknown>, derived, function() return op(get(a :: State<unknown>), b) end)
else--if bIsState then
link(b :: State<unknown>, derived, function() return op(a, get(b :: State<unknown>)) end)
end
derived.updated = true
return derived
end
end
function State.__index(_, index)
if index == "cache" then return nil end -- todo: better solution
error("attempt to index state", 2)
end
State.__concat = overload(function(a: any, b: any) return tostring(a) .. tostring(b) end)
State.__add = overload(function(a: any, b: any) return a + b end)
State.__sub = overload(function(a: any, b: any) return a - b end)
State.__mul = overload(function(a: any, b: any) return a * b end)
State.__div = overload(function(a: any, b: any) return a / b end)
--State.__eq = overload(function(a: any, b: any) return a == b end)
function create<T>(value: T): State<T>
return setmetatable({
cache = value,
updated = false,
derive = function() return nil end :: any,
effects = false :: false,
children = false :: false
}, State)
end
return table.freeze {
setEffect = setEffect,
get = get,
set = set,
unwrap = unwrap,
link = link,
capture = capture,
captureAndLink = captureAndLink,
wrapped = wrapped,
create = create,
}

84
src/init.lua Normal file
View file

@ -0,0 +1,84 @@
--------------------------------------------------------------------------------------------------------------
-- vide.lua
-- v0.1.0
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local create = require(script.create)
local apply = require(script.apply)
local wrap = require(script.wrap)
local watch = require(script.watch)
local derive = require(script.derive)
local map = require(script.map)
local unwrap = require(script.unwrap)
local wrapped = require(script.wrapped)
local Layout = require(script.Layout)
local Children = require(script.Children)
local Event = require(script.Event)
local Changed = require(script.Change)
local Created = require(script.Created)
local spring, updateSprings = require(script.spring)()
local flags = require(script.flags)
type Map<K, V> = { [K]: V }
type Unwrapper = <T>(T) -> T
type Setter<T> = ( (new: T, force: true?) -> T ) & ( (update: (old: T) -> T, force: true?) -> T )
local vide = {
-- core
create = create,
apply = apply,
wrap = (wrap :: any) :: <T>(value: T?) -> (T, Setter<T>),
derive = (derive :: any) :: <T>(deriver: (from: <U>(U) -> U) -> T, cleanup: (T) -> ()?) -> T,
map = (map :: any) :: ( <V>(input: number, transform: (number) -> V, cleanup: (V) -> ()?) -> Map<number, V> ) & ( <K, VI, VO>(input: Map<K, VI>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) -> Map<K, VO> ),
watch = watch :: ((Unwrapper) -> ()) -> () -> (),
-- util
unwrap = unwrap,
wrapped = wrapped,
-- animations
spring = (spring :: any) :: <T>(input: T, period: number, damping: number?) -> T,
-- symbols
Event = Event,
Changed = Changed,
Layout = Layout,
Children = Children,
Created = Created,
-- flags
strict = (nil :: any) :: boolean,
-- test
step = function(dt: number)
updateSprings(dt)
end
}
setmetatable(vide :: any, {
__index = function(_, index: unknown)
error(string.format("\"%s\" is not a valid member of vide", tostring(index)), 2)
end,
__newindex = function(_, index: unknown, value: unknown)
if index == "strict" then
flags.strict = if type(value) == "boolean" then value else error("strict must be a boolean", 2)
else
error(string.format("\"%s\" is not a valid member of vide", tostring(index)), 2)
end
end
})
if game then
game:GetService("RunService").Heartbeat:Connect(function(dt: number)
task.defer(vide.step, dt)
end)
end
return vide

76
src/map.lua Normal file
View file

@ -0,0 +1,76 @@
--------------------------------------------------------------------------------------------------------------
-- vide/map.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create
local get = graph.get
local wrapped = graph.wrapped
local link = graph.link
type Map<K, V> = { [K]: V }
local function map<K, VI, VO>(input: unknown, transform: (K, VI) -> VO, cleanup: (VO) -> ()?): (MaybeState<Map<K, VO>>)
if type(input) == "number" then
local output: Map<K, VO> = table.create(input) :: {}
for i = 1, input do
local v = transform(i :: any, nil :: any)
output[i :: any] = v
end
return output
elseif wrapped(input) then
local lastInput: Map<K, VI> = {}
local lastOutput: Map<K, VO> = {}
local output = create(lastOutput)
local function derive()
local newInput = get(input :: State<Map<K, VI>>)
if type(newInput) ~= "table" then error("Attempt to run map on a non-table state", 0) end
local lastInputClone = table.clone(lastInput)
for k, vi in next, newInput do
if vi ~= lastInputClone[k] then
local vo = transform(k, vi)
if cleanup and lastOutput[k] then cleanup(lastOutput[k]) end
lastInput[k] = vi
lastOutput[k] = vo
end
lastInputClone[k] = nil
end
for k, v in next, lastInputClone do
lastInput[k] = nil
if cleanup and lastOutput[k] then cleanup(lastOutput[k]) end
lastOutput[k] = nil
end
return table.clone(lastOutput)
end
link(input :: State<Map<K, VI>>, output, derive)
output.updated = true
return output
elseif type(input) == "table" then
local output: Map<K, VO> = table.create(#input :: any) :: {}
for k, vi in input :: Map<K, VI> do
local vo = transform(k, vi)
output[k] = vo
end
return output
else error(string.format("Invalid type arg #1, expected number or table or state (got %s)", tostring(input)), 2) end
end
return (map :: any) :: ( <V>(input: number, transform: (number) -> V) -> Map<number, V> ) &
( <K, VI, VO>(input: Map<K, VI>, transform: (K, VI) -> VO) -> Map<K, VO> ) &
( <K, VI, VO>(input: State<Map<K, VI>>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?)
-> State<Map<K, VO>> )

21
src/memoize.lua Normal file
View file

@ -0,0 +1,21 @@
--------------------------------------------------------------------------------------------------------------
-- vide/memoize.lua
--------------------------------------------------------------------------------------------------------------
local function memoize<X, Y>(f: (X) -> Y): ((X) -> Y, { [X]: Y })
local cache: { [X]: Y } = {}
return function(x: X): Y
local y: Y? = cache[x]
if not y then
y = f(x)
cache[x] = y
end
return y :: Y
end, cache
end
return memoize

169
src/spring.lua Normal file
View file

@ -0,0 +1,169 @@
--------------------------------------------------------------------------------------------------------------
-- vide/spring.lua
--------------------------------------------------------------------------------------------------------------
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
]]
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create
local get = graph.get
local set = graph.set
local unwrap = graph.unwrap
local wrapped = graph.wrapped
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
Alpha: number;
Duration: number;
Period: number;
Damping: number;
Velocity: number;
InitialVelocity: number;
Initial: T;
Target: T;
Input: State<T>;
}
type Lerp<T> = (initial: T, target: T, alpha: number) -> T
local function solve(T: number, z: number, u: number, t: number): number -- alpha
local wn = 2*math.pi / T
local wd = wn * math.sqrt(1 - z^2)
local a = z * wn
local s = math.exp(-a*t) * math.cos(wd*t)
local v = (u/wn) * math.exp(-a*t) * math.sin(wn*t)
return (1-s) + v
end
local lerpable: { [string]: Lerp<any> } = {
number = function(v1, v2, a)
return v1 + (v2 - v1)*a
end :: Lerp<number>,
CFrame = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<CFrame>,
Color3 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Color3>,
UDim = function(v1, v2, a)
return UDim.new(
v1.Scale + (v2.Scale - v1.Scale)*a,
v1.Offset + (v2.Offset - v1.Offset)*a
)
end :: Lerp<UDim>,
UDim2 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<UDim2>,
Vector2 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Vector2>,
Vector3 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Vector3>,
}
local activeSprings: { [State<any>]: SpringData<any> } = {}
setmetatable(activeSprings, { __mode = "ks" })
local function spring<T>(input: MaybeState<T>, period: number, damping: number?): State<T>
local initial = unwrap(input) :: T
local output = create(initial)
local springData: SpringData<T> = {
Alpha = 0,
Duration = 0,
Period = period,
Damping = damping or 1,
Velocity = 0,
InitialVelocity = 0,
Initial = initial,
Target = initial,
Input = input :: any,
LastInput = initial,
LastOutput = initial
}
activeSprings[output] = springData
return output
end
local function updateSprings(dt: number)
for node, data in next, activeSprings do
local currentTarget = get(data.Input)
if currentTarget ~= data.Target then
data.Target = currentTarget
data.Initial = get(node)
data.Target = get(data.Input)
data.Alpha = 0
data.Duration = 0
data.InitialVelocity = data.Velocity
end
local initial: Animatable = data.Initial
local target: Animatable = data.Target
local targetType: string = typeof(target)
if targetType ~= typeof(initial) then
activeSprings[node] = nil
warn(string.format(
"Mismatched state value types, cancelling state update (initial value: %s, target value: %s)",
typeof(initial),
targetType
))
throw(`Cannot tween type { typeof(initial) } and { targetType }`)
continue
end
local lerp: Lerp<Animatable> = lerpable[targetType]
if lerp == nil then
activeSprings[node] = nil
throw(`Cannot animate type { targetType }`)
continue
end
local newTime = data.Duration + dt
local newAlpha = solve(data.Period, data.Damping, data.InitialVelocity, newTime)
data.Velocity = -(newAlpha - data.Alpha)/dt
data.Alpha = newAlpha
data.Duration = newTime
local value = lerp(initial, target, newAlpha)
set(node, value)
end
end
return function() return spring, updateSprings end

15
src/throw.lua Normal file
View file

@ -0,0 +1,15 @@
------------------------------------------------------------------------------------------
-- vide/throw.lua
------------------------------------------------------------------------------------------
local function throw(msg: string)
local stack = 1
while debug.info(stack, "s") == debug.info(1, "s") do
stack += 1
end
error(msg, stack)
end
return throw

9
src/unwrap.lua Normal file
View file

@ -0,0 +1,9 @@
--------------------------------------------------------------------------------------------------------------
-- vide/unwrap.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
return graph.unwrap

36
src/watch.lua Normal file
View file

@ -0,0 +1,36 @@
--------------------------------------------------------------------------------------------------------------
-- vide/watch.lua
--------------------------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type Unwrapper = graph.Unwrapper
local setEffect = graph.setEffect
local capture = graph.capture
local unwrap = graph.unwrap
local function watch(effect: (Unwrapper) -> ()): () -> ()
local states, cleanup = capture(effect :: () -> (() -> ()?))
local function fn()
if cleanup then cleanup(); cleanup = nil end
cleanup = effect(unwrap)
end
for _, state in next, states do
setEffect(state, fn, true)
end
local function unwatch()
for _, state in next, states do
setEffect(state, fn, nil)
end
if cleanup then cleanup(); cleanup = nil end
end
return unwatch
end
return watch

42
src/wrap.lua Normal file
View file

@ -0,0 +1,42 @@
------------------------------------------------------------------------------------------
-- vide/wrap.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type State<T> = graph.State<T>
type MaybeState<T> = graph.MaybeState<T>
local create = graph.create
local get = graph.get
local set = graph.set
local wrapped = graph.wrapped
local throw = require(script.Parent.throw)
local flags = require(script.Parent.flags)
type Setter<T> = (value: MaybeState<T> | (MaybeState<T>) -> MaybeState<T>, force: boolean?) -> T
local function wrap<T>(value: MaybeState<T>?): (State<T>, Setter<T>)
local state = create(if wrapped(value) then get(value :: State<T>) else value :: T)
local function setter(vi: MaybeState<T> | (MaybeState<T>) -> MaybeState<T>, force: boolean?): T
if type(vi) == "function" then
vi = vi(get(state))
end
local v = if wrapped(vi) then get(vi :: State<T>) else vi :: T
if v ~= state.cache or force then
set(state, v)
elseif flags.strict and type(v) == "table" then
throw("attempt to set same table object")
end
return v
end
return state, setter
end
return wrap

9
src/wrapped.lua Normal file
View file

@ -0,0 +1,9 @@
------------------------------------------------------------------------------------------
-- vide/wrapped.lua
------------------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
return graph.wrapped

155
test/benchmark.luau Normal file
View file

@ -0,0 +1,155 @@
------------------------------------------------------------------------------------------
-- benchmark.lua
------------------------------------------------------------------------------------------
local BENCH, START = require("test/testkit").getBenchmarkTools()
local vide = require "src/init"
type State<T> = vide.State<T>
local function gc(n: number?)
for i = 1, n or 3 do
(collectgarbage :: any)("collect")
end
end
local N = 1e5
gc()
BENCH("Create state", function()
local cache = table.create(N)
local wrap = vide.wrap
for i = 1, START(N) do
cache[i] = wrap(1)
end
end)
gc()
BENCH("Get state value", function()
local state = vide.wrap(1)
local unwrap = vide.unwrap
for i = 1, START(N) do
unwrap(state)
end
end)
gc()
BENCH("Set state value", function()
local _, set = vide.wrap(1)
for i = 1, START(N) do
set(i)
end
end)
gc()
BENCH("Derive state", function()
local cache = table.create(N)
local state = vide.wrap(1)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function(from)
return from(state)
end)
end
end)
gc()
BENCH("Derive state (2)", function()
local cache = table.create(N)
local state = vide.wrap(1)
local state2 = vide.wrap(2)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function(from)
return from(state) + from(state2)
end)
end
end)
gc()
BENCH("Derive state (shorthand)", function()
local cache = table.create(N)
local state = vide.wrap(1)
for i = 1, START(N) do
cache[i] = state + 1
end
end)
gc()
BENCH("Derive state (shorthand 2)", function()
local cache = table.create(N)
local state = vide.wrap(1)
local state2 = vide.wrap(2)
for i = 1, START(N) do
cache[i] = state + state2
end
end)
gc()
BENCH("Derived state update", function()
local stateA, setA = vide.wrap(1)
local stateB = vide.derive(function(from) return from(stateA) end)
local unwrap = vide.unwrap
for i = 1, START(N) do
setA(i)
unwrap(stateB)
end
end)
gc()
BENCH("Derived state update (shorthand)", function()
local stateA, setA = vide.wrap(1)
local stateB = stateA + 1
local unwrap = vide.unwrap
for i = 1, START(N) do
setA(i)
unwrap(stateB)
end
end)
gc()
BENCH("Apply single property", function()
local instance = vide.create("Frame") {}
local apply = vide.apply
for i = 1, START(N) do
apply(instance) {
Name = i
}
end
end)
gc()
BENCH("Bind state", function()
local instance = vide.create("Frame") {}
local state = vide.wrap(1)
local apply = vide.apply
for i = 1, START(N) do
apply(instance) {
Name = state
}
end
end)
return nil

187
test/goodsignal.lua Normal file
View file

@ -0,0 +1,187 @@
-- wrapped task library for use in pure luau
local task = { spawn = function(thread, ...)
local ok, err = coroutine.resume(thread, ...)
if not ok then error(err, 3) end
end }
----------------------------------------------------------------------------------------------------
-- Batched Yield-Safe Signal Implementation --
-- This is a Signal class which has effectively identical behavior to a --
-- normal RBXScriptSignal, with the only difference being a couple extra --
-- stack frames at the bottom of the stack trace when an error is thrown. --
-- This implementation caches runner coroutines, so the ability to yield in --
-- the signal handlers comes at minimal extra cost over a naive signal --
-- implementation that either always or never spawns a thread. --
-- --
-- API: --
-- local Signal = require(THIS MODULE) --
-- local sig = Signal.new() --
-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) --
-- sig:Fire(arg1, arg2, ...) --
-- connection:Disconnect() --
-- sig:DisconnectAll() --
-- local arg1, arg2, ... = sig:Wait() --
-- --
-- Licence: --
-- Licenced under the MIT licence. --
-- --
-- Authors: --
-- stravant - July 31st, 2021 - Created the file. --
----------------------------------------------------------------------------------------------------
-- The currently idle thread to run the next handler on
local freeRunnerThread = nil
-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
local acquiredRunnerThread = freeRunnerThread
freeRunnerThread = nil
fn(...)
-- The handler finished running, this runner thread is free again.
freeRunnerThread = acquiredRunnerThread
end
-- Coroutine runner that we create coroutines of. The coroutine can be
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread()
-- Note: We cannot use the initial set of arguments passed to
-- runEventHandlerInFreeThread for a call to the handler, because those
-- arguments would stay on the stack for the duration of the thread's
-- existence, temporarily leaking references. Without access to raw bytecode
-- there's no way for us to clear the "..." references from the stack.
while true do
acquireRunnerThreadAndCallEventHandler(coroutine.yield())
end
end
-- Connection class
local Connection = {}
Connection.__index = Connection
function Connection.new(signal, fn)
return setmetatable({
_connected = true,
_signal = signal,
_fn = fn,
_next = false,
}, Connection)
end
function Connection:Disconnect()
self._connected = false
-- Unhook the node, but DON'T clear it. That way any fire calls that are
-- currently sitting on this node will be able to iterate forwards off of
-- it, but any subsequent fire calls will not hit it, and it will be GCed
-- when no more fire calls are sitting on it.
if self._signal._handlerListHead == self then
self._signal._handlerListHead = self._next
else
local prev = self._signal._handlerListHead
while prev and prev._next ~= self do
prev = prev._next
end
if prev then
prev._next = self._next
end
end
end
-- Make Connection strict
setmetatable(Connection, {
__index = function(tb, key)
error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
end,
__newindex = function(tb, key, value)
error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
end
})
-- Signal class
local Signal = {}
Signal.__index = Signal
function Signal.new()
return setmetatable({
_handlerListHead = false,
}, Signal)
end
function Signal:Connect(fn)
if type(fn) ~= "function" then error(`attempt to connect non function (got { type(fn) })`, 2) end
local connection = Connection.new(self, fn)
if self._handlerListHead then
connection._next = self._handlerListHead
self._handlerListHead = connection
else
self._handlerListHead = connection
end
return connection
end
-- Disconnect all handlers. Since we use a linked list it suffices to clear the
-- reference to the head handler.
function Signal:DisconnectAll()
self._handlerListHead = false
end
-- Signal:Fire(...) implemented by running the handler functions on the
-- coRunnerThread, and any time the resulting thread yielded without returning
-- to us, that means that it yielded to the Roblox scheduler and has been taken
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
function Signal:Fire(...)
local item = self._handlerListHead
while item do
if item._connected then
if not freeRunnerThread then
freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
-- Get the freeRunnerThread to the first yield
coroutine.resume(freeRunnerThread)
end
task.spawn(freeRunnerThread, item._fn, ...)
end
item = item._next
end
end
-- Implement Signal:Wait() in terms of a temporary connection using
-- a Signal:Connect() which disconnects itself.
function Signal:Wait()
local waitingCoroutine = coroutine.running()
local cn;
cn = self:Connect(function(...)
cn:Disconnect()
task.spawn(waitingCoroutine, ...)
end)
return coroutine.yield()
end
-- Implement Signal:Once() in terms of a connection which disconnects
-- itself before running the handler.
function Signal:Once(fn)
local cn;
cn = self:Connect(function(...)
if cn._connected then
cn:Disconnect()
end
fn(...)
end)
return cn
end
-- Make signal strict
setmetatable(Signal, {
__index = function(tb, key)
error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2)
end,
__newindex = function(tb, key, value)
error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2)
end
})
return Signal

230
test/mock.luau Normal file
View file

@ -0,0 +1,230 @@
local Instance = {} do
local Signal = require "test/goodsignal"
type userdata = { __USERDATA__: true }
type Proxy = {
_Userdata: userdata,
_Data: Data,
__index: any,
__newindex: any
}
type Data = {
Name: string,
Parent: Data?,
Children: { Data },
Changed: { [string]: RBXScriptSignal & { Fire: any } },
Table: { [string]: unknown },
Destroying: RBXScriptSignal,
ClassName: string,
Type: "Instance"
}
--[[
attempt to mimic roblox engine's method of userdata proxy to actual instance data
proxy can gc independantly of actual instance data
proxy prevents gc of actual instance data
luau code never has direct access to actual instance data, only to proxy
]]
local proxies = {} :: { [Data]: userdata? }
setmetatable(proxies :: any, { __mode = "v" })
local function getdata(userdata: userdata): Data
local function getproxy(userdata: userdata): Proxy
return getmetatable(userdata :: any)
end
return getproxy(userdata)._Data
end
local function isInstance(value: unknown): boolean
local mt = getmetatable(value :: any)
return mt and mt._Data and mt._Data.Type == "Instance"
end
local methods = {}
local function __index(userdata: userdata, property: string): ()
local data = getdata(userdata)
return if methods[property] then methods[property]
elseif property == "Name" then data.Name
elseif property == "Parent" then data.Parent
elseif property == "Destroying" then data.Destroying
else data.Table[property]
end
local function __newindex(userdata: userdata, property: string, value: unknown)
local data = getdata(userdata)
if property == "Name" then
data.Name = value :: string
elseif property == "Parent" then
assert(value == nil or isInstance(value), "attempt to set non-instance as parent")
local parent = data.Parent
if parent then
data.Parent = nil
table.remove(parent.Children, table.find(parent.Children, data))
end
if value then
data.Parent = getdata(value :: userdata)
table.insert(getdata(value :: userdata).Children, data)
end
else
data.Table[property] = value
end
if data.Changed[property] then
data.Changed[property]:Fire()
end
end
local function getuserdata(data: Data): userdata
return proxies[data] or (function()
local userdata = newproxy(true)
local proxy = getmetatable(userdata)
proxy._Userdata = userdata
proxy._Data = data
proxy.__index = __index
proxy.__newindex = __newindex
proxies[data] = userdata
return userdata
end)()
end
function Instance.new(class: string): Instance
local data = {
Name = "UNNAMED",
Parent = nil,
Children = {},
Changed = {},
Table = {},
ClassName = class,
Destroying = Signal.new() :: any,
Type = "Instance" :: "Instance"
}
return getuserdata(data) :: any
end
function Instance.isInstance(value: unknown): boolean
return isInstance(value)
end
function methods.Clone(userdata: userdata): userdata
local data = getdata(userdata)
local clone_userdata = (Instance.new("") :: any) :: userdata
local clone_data = getdata(clone_userdata)
table.clear(clone_data)
for i, v in next, data do
clone_data[i] = type(v) == "table" and table.clone(v) or v
end
return clone_userdata
end
function methods.FindFirstChild(userdata: userdata, target: string): userdata?
local data = getdata(userdata)
for _, child in data.Children do
if child.Name == target then
return getuserdata(child)
end
end
return nil
end
function methods.GetChildren(userdata: userdata): { userdata }
local children = getdata(userdata).Children
local userdatas = table.create(#children)
for i, child in next, children do
userdatas[i] = getuserdata(child)
end
return userdatas
end
function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal
local data = getdata(userdata)
if not data.Changed[property] then
data.Changed[property] = Signal.new() :: any
end
return data.Changed[property]
end
function methods.Destroy(userdata: userdata)
local data = getdata(userdata);
(data.Destroying :: any):Fire()
data.Parent = nil
if data.Changed["Parent"] then
data.Changed["Parent"]:Fire()
end
end
end
local Color3 = {} do
function Color3.new(r, g, b): Color3
return setmetatable({ r = r, g = g, b = b}, Color3) :: any
end
function Color3.__eq(a, b)
return a.r == b.r and a.g == b.g and a.b == b.b
end
end
local Vector3 = {} do
function Vector3.new(x, y, z): Vector3
return setmetatable({ x = x, y = y, z = z}, Vector3) :: any
end
function Vector3.__eq(a, b)
return a.x == b.x and a.y == b.y and a.z == b.z
end
end
local Vector2 = {} do
function Vector2.new(x, y): Vector2
return setmetatable({ x = x, y = y }, Vector2) :: any
end
function Vector2.__eq(a, b)
return a.x == b.x and a.y == b.y
end
end
local UDim2 = {} do
function UDim2.fromScale(x, y): UDim2
return setmetatable({ x = { scale = x, offset = 0 }, y = { scale = y, offset = 0 } }, UDim2) :: any
end
function UDim2.__eq(a, b)
return a.x.scale == b.x.scale and
b.x.offset == b.x.offset and
a.y.scale == b.y.scale and
a.y.offset == b.y.offset
end
end
local Enum = {} :: any do
setmetatable(Enum, { __index = function(self, index)
local v = setmetatable({}, { __index = function(self, index)
self[index] = true
return true
end})
self[index] = v
return v
end})
end
local function typeof(v)
return Instance.isInstance(v) and "Instance" or type(v)
end
return {
Instance = Instance,
Color3 = Color3,
Vector3 = Vector3,
Vector2 = Vector2,
UDim2 = UDim2,
Enum = Enum,
typeof = typeof
}

72
test/syntax.luau Normal file
View file

@ -0,0 +1,72 @@
local vide = require "src/init"
local wrap = vide.wrap
local derive = vide.derive
local map = vide.map
local create = vide.create
local unwrap = vide.unwrap
local Event = vide.Event
local Layout = vide.Layout
do
local count = wrap(0)
local count2 = derive(function(from)
local c = from(count) * unwrap(count)
return c ^ 2
end)
local x = count2.value
local count2b = count + 1
local xb = unwrap(count2b)
end
local count = wrap(0)
local v = count.value
do
local t = map(1, function(i)
return ""
end)
local t = map({ true }, function(i, v)
return 1
end)
local data = wrap { "" }
local t = map(data, function(i, v)
return 1
end)
end
create("Frame") {
}
local Value, Computed, peek, OnEvent = nil :: any, nil :: any, nil :: any, nil :: any
local New = nil :: any
local function Counter(props)
local count, set = wrap(0)
return create("TextLabel") {
Text = "Count: " .. count,
[Layout] = props[Layout],
[Event.Activated] = function()
set(count + 1)
end
}
end
local function Frame(props)
return create("Frame") {
Position = props.Position
}
end
Frame { Position = UDim2.fromScale(0.5, 0.5) }
Frame { Position = positionState }

321
test/testkit.luau Normal file
View file

@ -0,0 +1,321 @@
------------------------------------------------------------------------------------------
-- testkit.luau
-- v0.2.0
------------------------------------------------------------------------------------------
--[[
EXAMPLE USAGE:
local testkit = require "path-to-testkit"
local TEST, CASE, CHECK = testkit.getUnitTestTools()
TEST("test name", function()
do CASE "A"
CHECK(condition)
end
end)
local BENCH, START = testkit.getBenchmarkTools()
BENCH("benchmark name", function()
local x = 0
for i = 1, START(1e6) do
x += 1
end
end)
]]
------------------------------------------------------------------------------------------
-- Unit Testing
------------------------------------------------------------------------------------------
type Test = {
name: string,
activeCase: Case?,
cases: { Case },
duration: number,
error: string?
}
type Case = {
name: string,
result: number,
line: number?
}
local PASS = 1
local FAIL = 2
local NONE = 3
local ERROR = 4
local activeTest: Test?
local tests: { Test } = {}
local function outputTestResults(test: Test)
print("\27[1;4m"..test.name.."\27[0m")
for _, case in test.cases do
print(
"[" ..
(if case.result == PASS then
"\27[32;1mPASS\27[0m"
elseif case.result == FAIL then
"\27[31;1mFAIL:"..assert(case.line).."\27[0m"
elseif case.result == NONE then
"\27[33;1mNONE\27[0m"
else
"\27[41;1;30mERROR\27[0m")
.. "] " .. case.name
)
end
if test.error then
print("\27[31;1;30merror: " .. test.error .. "\27[0m")
end
print ""
end
local function CASE(name: string)
assert(activeTest, "no active test")
local case: Case = {
name = name,
result = NONE
}
activeTest.activeCase = case
table.insert(activeTest.cases, case)
end
local function CHECK(value: any): boolean
assert(activeTest, "no active test")
local activeCase = activeTest.activeCase
if not activeCase then
CASE ""
activeCase = activeTest.activeCase
end; assert(activeCase, "no active case")
local result = value and PASS or FAIL
if activeCase.result == NONE or activeCase.result == PASS then
activeCase.result = result
activeCase.line = debug.info(2, "l")
end
return result == PASS
end
local function TEST(name: string, fn: () -> ())
assert(not activeTest, "new test was started while a test was in progress")
local test: Test = {
name = name,
cases = {},
duration = 0
}
activeTest = test
table.insert(tests, test)
local start = os.clock()
local msg: string?
local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end)
test.duration = os.clock() - start
if not test.activeCase then CASE "" end
assert(test.activeCase, "no active case")
if not success then
test.activeCase.result = ERROR
test.error = msg
end
activeTest = nil
outputTestResults(test)
end
local function FINISH(): boolean
local success = true
local totalCases = 0
local passedCases = 0
local duration = 0
for _, test in tests do
duration += test.duration
for _, case in test.cases do
totalCases += 1
if case.result == PASS or case.result == NONE then
passedCases += 1
else
success = false
end
end
end
print(string.format("%d/%d test cases passed in %.3f ms.", passedCases, totalCases, duration*1e3))
local fails = totalCases - passedCases
print(string.format("\27[%d;1;30m%d fail%s\27[0m", fails > 0 and 41 or 42, fails, fails == 1 and "" or "s"))
return success, table.clear(tests)
end
------------------------------------------------------------------------------------------
-- Benchmarking
------------------------------------------------------------------------------------------
type Bench = {
timeStart: number?,
memStart: number?,
iterations: number?
}
local activeBench: Bench? = nil
function START(iter: number?): number
local n = iter or 1
if n < 1 then error("iteration count must be greater than 0", 2) end
assert(activeBench, "no active benchmark")
assert(not activeBench.timeStart, "clock was already started")
activeBench.iterations = n
activeBench.memStart = gcinfo()
activeBench.timeStart = os.clock()
return n
end
local function BENCH(name: string, fn: () -> ())
assert(not activeBench, "cannot run benchmark, a benchmark is already in progress")
local bench: Bench = {}
activeBench = bench
local memStart = gcinfo()
local timeStart = os.clock()
local msg: string?
local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end)
local timeStop = os.clock()
local memStop = gcinfo()
if not success then
print("[\27[41;1mERROR\27[0m] " .. name)
print("\27[31;1m" .. "error: " .. msg :: string .. "\27[0m")
activeBench = nil
return
end
timeStart = bench.timeStart or timeStart
memStart = bench.memStart or memStart
local n = bench.iterations or 1
local duration = timeStop - timeStart
local allocated = memStop - memStart
print(string.format("[ %.3f us | %4.0f B ] %s", duration/n * 1e6, allocated/n * 1e3, name))
activeBench = nil
end
------------------------------------------------------------------------------------------
-- Printing
------------------------------------------------------------------------------------------
local function printa(v: unknown)
type Buffer = { n: number, [number]: string }
-- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer)
local TAB = " "
local indent = table.concat(table.create(stack, TAB))
if type(value) == "string" then
local n = str.n
str[n + 1] = "\""
str[n + 2] = value
str[n + 3] = "\""
str.n = n + 3
elseif type(value) ~= "table" then
local n = str.n
str[n + 1] = value == nil and "nil" or tostring(value)
str.n = n + 1
elseif next(value) == nil then
local n = str.n
str[n + 1] = "{}"
str.n = n + 1
else
local tabbed_indent = indent .. TAB
str.n += 1
str[str.n] = "{\n"
local i, v = next(value, nil)
while v ~= nil do
local n = str.n
str[n + 1] = tabbed_indent
if type(i) ~= "string" then
str[n + 2] = "["
str[n + 3] = tostring(i)
str[n + 4] = "]"
n += 4
else
str[n + 2] = tostring(i)
n += 2
end
str[n + 1] = " = "
str.n = n + 1
tos(v, stack + 1, str)
i, v = next(value, i)
n = str.n
str[n + 1] = v ~= nil and ",\n" or "\n"
str.n = n + 1
end
local n = str.n
str[n + 1] = indent
str[n + 2] = "}"
str.n = n + 2
end
end
local str = { n = 0 }
tos(v, 0, str)
print(table.concat(str))
end
printa "string"
printa(1)
printa {
hello = 1,
bye = "ok",
test = {
1, 2, 3
}
}
------------------------------------------------------------------------------------------
-- Return
------------------------------------------------------------------------------------------
return {
getUnitTestTools = function()
return TEST, CASE, CHECK, FINISH
end,
getBenchmarkTools = function()
return BENCH, START
end,
printa = printa
}

1293
test/tests.luau Normal file

File diff suppressed because it is too large Load diff

8
test/wrap-require.lua Normal file
View file

@ -0,0 +1,8 @@
local function dir(directory: string)
return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end })
end
local script = dir "src/"
script.Parent = dir "src/"
return script

6
todo.md Normal file
View file

@ -0,0 +1,6 @@
# Todo
- investigate solid js `for` and batching
- equality checking of derived state updates to prevent redundant updates of derived state
- watcher callbacks when watcher watches a parent and child
- stores

26
vide.code-snippets Normal file
View file

@ -0,0 +1,26 @@
{
"import vide": {
"scope": "lua",
"prefix": "getvide",
"body": [
"local vide = require($1.vide)",
"type State<T> = vide.State<T>",
"type Prop<T> = vide.Prop<T>",
"local create = vide.create",
"local apply = vide.apply",
"local wrap = vide.wrap",
"local derive = vide.derive",
"local foreach = vide.foreach",
"local match = vide.match",
"local watch = vide.watch",
"local spring = vide.spring",
"local Event = vide.Event",
"local Changed = vide.Changed",
"local Bind = vide.Bind",
"local Layout = vide.Layout",
"local Children = vide.Children",
"local Created = vide.Created",
""
]
}
}

10
wally.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "centau/ecr"
description = "A fast, lightweight Luau ECS library."
version = "0.4.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
include = ["src", "LICENSE.md", "default.project.json"]
exclude = ["CHANGELOG.md", ".gitignore", ".github", ".gitattributes", "test", "docs", "README.md", ".luaurc"]
[dependencies]