Initial commit

This commit is contained in:
aaron 2023-08-08 21:33:11 +01:00
commit cb002f4f27
50 changed files with 4666 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

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

55
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: site-deploy
on:
push:
branches: [main] # todo: remove later
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
defaults:
run:
working-directory: docs
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Install dependencies
run: npm install
- name: Build with VitePress
run: npm run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
with:
path: docs/.vitepress/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2

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

@ -0,0 +1,25 @@
name: unit-test
on:
push:
pull_request:
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

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.vscode
_local
aftman.toml
sourcemap.json
docs/.vitepress/dist
docs/.vitepress/cache

6
.luaurc Normal file
View file

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

13
CHANGELOG.md Normal file
View file

@ -0,0 +1,13 @@
# 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.1.0] - 0000-00-00
- 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.

35
README.md Normal file
View file

@ -0,0 +1,35 @@
### ⚠️ This library is in early stages of development with breaking changes being made often.
Vide is a reactive and declarative UI library.
- Uses Luau typechecking
- Declarative and concise syntax.
- Minimal imports.
- Reactive state driven.
## Getting started
Read the
[crash course](https://centau.github.io/vide/tut/crash-course/1-introduction)
for a quick introduction to the library.
## Code sample
```lua
local vide = require(path_to_vide)
local source = vide.source
local function Counter()
local count = source(0)
return create "TextButton" {
Text = function()
return "count: " .. count()
end,
Activated = function()
count(count() + 1)
end
}
end
```

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" }
}

57
docs/.vitepress/config.ts Normal file
View file

@ -0,0 +1,57 @@
import { defineConfig } from "vitepress"
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Vide",
description: "A declarative and reactive library for Luau.",
base: "/vide/",
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: "Home", link: "/" },
{ text: "Tutorials", link: "/tut/crash-course/1-introduction" },
{ text: "API", link: "/api/reactivity-core"},
{ text: "GitHub", link: "https://github.com/centau/vide" }
],
sidebar: {
"/api/": [
{
text: "API",
items: [
{ text: "Reactivity: Core", link: "/api/reactivity-core" },
{ text: "Reactivity: Utility", link: "/api/reactivity-utility" },
{ text: "Element Creation", link: "/api/creation" },
{ text: "Animation", link: "/api/animation" },
{ text: "Strict Mode", link: "/api/strict-mode" },
]
}
],
"/tut/": [
{
text: "Crash Course",
items: [
{ text: "Introduction", link: "/tut/crash-course/1-introduction" },
{ text: "Element Creation", link: "/tut/crash-course/2-creation" },
{ text: "Components", link: "/tut/crash-course/3-components" },
{ text: "State", link: "/tut/crash-course/4-state" },
{ text: "Derived State", link: "/tut/crash-course/5-derived-state" },
{ text: "Table State", link: "/tut/crash-course/6-table-state" },
{ text: "Property Groups", link: "/tut/crash-course/7-property-groups" },
]
},
{
text: "Tutorials",
items: [
{ text: "Crash Course", link: "/tut/crash-course/index" },
]
}
],
}
// socialLinks: [
// { icon: "github", link: "https://github.com/centau/vide" }
// ]
}
})

View file

@ -0,0 +1,4 @@
// .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import './vars.css'
export default DefaultTheme

View file

@ -0,0 +1,7 @@
:root {
--vp-c-brand: #3086ff;
--vp-c-green-lighter: #02a5fd;
--vp-c-green-dark: #2b4efd;
--vp-c-green-darker: #5c2bfd;
}

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

@ -0,0 +1,37 @@
# Animation API
## spring()
Returns a new state with a dynamically animated value of the source.
- **Type**
```lua
function spring<T>(
source: () -> T & Animatable,
period: number = 1,
damping_ratio: number = 1
): () -> T
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
```
- **Details**
The output state value is updated every frame based on the source state
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 oscillation.
`damping_ratio` is the amount of resistance applied to the spring.
- \>1 = Overdamped (not currently supported).
- 1 = Critically damped - reaches target without any overshoot.
- <1 = Underdamped - reaches target with some overshoot.
- 0 = Undamped - never stabilizes, oscillates forever.
Velocity is conserved between source state updates for smooth animation.

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

@ -0,0 +1,79 @@
# Element Creation API
<br/>
## create()
Creates a new UI element, applying any given properties.
- ### Type
```lua
function create(class: string): (Properties) -> Instance
function create(instance: Instace): (Properties) -> Instance
type Properties = Map<string|number, any>
```
- ### Details
The function can take either a `string` or an `Instance` as its first argument.
- If given a `string`, a new instance with the same class name will be created.
- 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.
- ### Property setting rules
- If a table value is another table, that nested table is processed so that
any properties inside that table are also applied to the instance just
like the outer table.
- If a table index is a string:
- If its value is a function then it will either bind that property to
a state or connect it if the property type is a `RBXScriptSignal`.
- If the value is not a function then the property will be set to that
value.
- If a table index is a number:
- If its value is a function then it will parent any instances returned by
that function as children.
- If its value is an instance then it will be parented to the instance.
- ### Example
Basic element creation.
```lua
local frame = create "Frame" {
Name = "NewFrame",
Position = UDim2.fromScale(1, 0)
}
```
A component using property nesting/grouping.
```lua
type Layout = {
Layout = {
Position: UDim2?,
Size: UDim2?,
AnchorPoint: Vector2?
}
}
type Children = {
Children = Array<Instance>
}
function Background(props: Layout & Children & {
Color: Color3
})
return create "Frame" {
BackgroundColor3 = Color,
props.Layout,
props.Children
}
end
```

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

@ -0,0 +1,250 @@
# Reactivity API: Core
<br/>
## source()
Creates a new source state with the given value.
- **Type**
```lua
function source<T>(value: T): (T?) -> T
```
- **Details**
Calling the returned state with no arguments will return its stored value,
calling with arguments will set a new value.
Reading from the state from within any reactive scope will cause changes
to that state to be tracked and anything depending on it to update.
- **Example**
```lua
local count = source(0)
count() -- 0
count(count() + 1) -- 1
```
## watch()
Runs a callback on state change.
- **Type**
```lua
function watch(callback: () -> ()): Unwatch
type Unwatch = () -> ()
```
- **Details**
The callback is ran immediately to determine what states are referenced.
Any time a state referenced in the callback is changed, the callback will be
reran.
Also returns a function that when called, stops the watcher immediately.
::: warning
`callback()` cannot yield.
:::
- **Example**
```lua
local state = wrap(1)
watch(function()
print(state.Value)
end)
-- prints 1
state.Value += 1
-- prints 2
```
## derive()
Derives a new state from existing states.
- **Type**
```lua
function derive<T>(source: () -> T): () -> T
```
- **Details**
The derived state will have its value recalculated when any source state it
derives from is updated.
Anytime its value is recalculated it is also cached, subsequent calls will
retun this cached value until it recalculates again.
Takes a callback that is immediately run to determine what states are being
referenced.
::: warning
`source()` cannot yield.
:::
- **Example**
```lua
local count = wrap(0)
local text = derive(function() return `count: {count()}` end)
text() -- "count: 0"
count(1)
text() -- "count: 1"
```
## indexes()
Maps each index in a table to an object.
- **Type**
```lua
function indexes<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: () -> VI, index: KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each index in the
source table. The first argument is a state containing the index's value and
the second argument is just the index.
Anytime a new index is added, the transform function will be called again for
that new index.
Anytime an existing index value changes, the transform function is not rerun,
instead the passed state for that index will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the transform.
::: warning
`transform()` cannot yield.
:::
- **Example**
The intended purpose of this function is to map each index in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = indexes(numbers, function(item, i)
return ItemDisplay {
Name = function()
return item().name
end,
Image = function()
return "rbxassetid://" .. item().icon
end,
LayoutOrder = i
}
end)
```
## values()
Maps each value in a table to an object.
- **Type**
```lua
function values<KI, VI, VO>(
source: () -> Map<KI, VI>,
transform: (value: VI, index: () -> KI) -> VO
): Array<VO>
- **Details**
The transform function is called only ever *once* for each value in the
source table. The first argument is the index's value and
the second argument is a state containing the index.
Anytime a new value is added, the transform function will be called again
for that new value.
Anytime an existing value's index changes, the transform function is not
rerun, instead the passed state for that value will update, causing anything
depending on it to update too.
Returns a state containing an array of all objects returned by the transform.
::: warning
`transform()` cannot yield.
:::
- **Example**
The intended purpose of this function is to map each value in a table to
a UI element.
```lua
type Item = {
name: string,
icon: number
}
local items = source {} :: () -> Array<Item>
local displays = values(numbers, function(item, i)
return ItemDisplay {
Name = item.Name
Image = "rbxassetid://" .. item.icon,
LayoutOrder = i
}
end)
```
- **Extra**
When should you use `indexes()` and `values()`?
`values()` should be used when you have a fixed set of objects where the
same objects can be re-arranged in the source table. It maps a value to a
UI element.
e.g.
- List of all players.
- Inventory of items.
- Chat message history.
- Toast notifications.
`indexes()` should be used in other cases, especially when your source table
has primitive value. It maps an index to a UI element.
e.g.
- List of character or weapon stats.
In most cases, both functions will appear to have the same behavior.
The main difference is performance, picking the right function to use can
result in less property updates and less re-renders.
--------------------------------------------------------------------------------

View file

@ -0,0 +1,25 @@
# Reactivity API: Utility
## cleanup()
Runs a callback anytime a reactive scope is re-ran.
- **Type**
```lua
function cleanup(callback: () -> ())
```
- **Example**
```lua
local data = source(1)
watch(function()
local label = create "TextLabel" { Text = data }
cleanup(function()
label:Destroy()
end)
end)
```

25
docs/api/strict-mode.md Normal file
View file

@ -0,0 +1,25 @@
# Strict Mode
Vide has a special mode called "strict mode" which is used for debugging.
The purpose of strict mode is to help ensure stateful code is *pure*
(deterministic and free from side effects) or if there are side-effects, that
they are cleaned up correctly.
Vide is set to strict by doing:
```lua
local vide = require(path_to_vide)
vide.strict = true
```
What strict mode will do:
1. Run derived callbacks twice when re-evaluating.
2. Run watcher callbacks twice when a state changes.
3. Throw an error if yields occur where they are not allowed.
4. Checks for `map()` returning primitive values.
5. Better error reporting and stack traces.
It is recommend to develop UI with strict mode and to disable it when pushing to
production.

21
docs/index.md Normal file
View file

@ -0,0 +1,21 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: Vide
text: ""
tagline: A declarative and reactive library for Luau.
actions:
- theme: brand
text: Tutorials
link: /tut/crash-course/1-introduction
- theme: alt
text: API Reference
link: /api/reactivity-core
features:
- title: In Development
details: Do not use for production
---

13
docs/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"type": "module",
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.0.0-beta.6"
}
}

View file

@ -0,0 +1,23 @@
# Introduction
This is a brief tutorial designed to give you a quick run through the usage of
Vide.
Vide is largely inspired by other UI libraries such as Solid and Fusion.
## Why Vide?
Creating UI is a slow and tedious process. The purpose of Vide is to make UI
declarative and concise, making it faster to create and more importantly easier
to maintain. Vide achieves this using a reactive style of programming which
allows you to focus on the flow of data through your application without
worrying about manually updating UI instances.
Some of the main focuses behind Vide's design choices:
- Concise syntax to reduce verbosity as much as possible.
- Reducing the amount of imports needed for usage by using Luau's syntax and
semantics.
- Being completely typecheckable.
- Flexibility, particularly with integrating other libraries and allowing users
to use their own patterns.

View file

@ -0,0 +1,60 @@
# Creating UI Elements
Instances are created using [`create()`](../../api/creation.md#create).
```lua
local vide = require(path_to_vide)
local create = vide.create
```
`create()` returns a constructor for a given class which then takes a table of
properties to assign when creating a new instance for that class.
```lua
local frame = create "Frame" {
Name = "Background",
Position = UDim2.fromScale(0.5, 0.5)
}
```
String keys are assigned as properties and integer keys are assigned as child
instances.
```lua
create "ScreenGui" {
Parent = game.StarterGui,
create "Frame" {
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.fromScale(0.4, 0.7),
create "TextLabel" {
Text = "hi"
},
create"TextLabel" {
Text = "bye"
}
}
}
```
To connect to an event, just set the event property name to a function.
All event arguments are passed into the function.
```lua
create "TextButton" {
Activated = function()
print "clicked!"
end
}
```
In short:
- String keys = properties
- Function values = events
- Non-function values = property values
- Numeric keys = children

View file

@ -0,0 +1,47 @@
# Components
Components are custom-made reusable pieces of UI made from other pieces of UI.
Using components you make your application more modular and better organized.
Components leverage functions to create self-contained UI that can even have
its own state and behavior.
```lua
local function Button(props: {
Position: UDim2,
Text: string,
Callback: () -> ()
})
return create "TextButton" {
BackgroundColor3 = Color3.fromRGB(50, 50, 50),
Size = UDim2.fromOffset(400, 250),
Position = props.Position,
Text = props.Text,
Callback = props.Callback
}
end
local button = Button {
Position = UDim2.new(),
Text = "Click me!",
Callback = function()
print "clicked"
end
}
```
Above is a simple example of a button component with its background color set to
a dark grey and with a fixed size.
A single parameter `props` is used to pass properties to the component.
Components allow you to *encapsulate* behavior. You can only modify the
component in ways that you allow in the component.
This also promotes code reusability. Anytime you want a new button all you do
is call `Button {}` instead of creating and setting every property each time.
This can be extended to much more complicated UI.

View file

@ -0,0 +1,56 @@
# State
State in Vide are the core of reactivity in Vide.
State contain values that can change, and when they do change, automatically
update anything that is using it.
A state object in Vide can be created using
[`source()`](../../api/reactivity-core.md#source).
```lua
local source = vide.source
```
```lua
local count = source(0)
```
The value of a state can be set by calling it with an argument, and can be read
by calling it with no arguments.
```lua
count(count() + 1) -- increment count state by 1
```
Below is an example of a counter component that has state.
```lua
local function Counter()
local count = source(0)
return create "TextButton" {
Text = count,
Activated = function()
count(count() + 1)
end
}
end
```
Any time the source value is set, anything depending on it will automatically be
updated using the new value.
Vide detects when you assign a function to a property. This is known
as *binding* and doing so will cause the property to *automatically* update
whenever a state in that function is updated, by rerunning the function and
assigning its return value. You can only bind non-event
properties, otherwise the function is connected as the event callback.
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 update when changes occur.
Each call of `Counter {}` will create a new counter element, each with their own
independent count state.

View file

@ -0,0 +1,58 @@
# Derived State
You can create new state from existing states. This is known as *deriving
state*.
A function that wraps a state effectively becomes a state. If a state used
inside a function is updated, the whole function can be re-ran to recompute
its value.
```lua
local count = source(0)
local function text()
return "count: " .. count()
end
create "TextLabel" {
Text = text
}
```
Sometimes when using expensive computations to derive state, you only want to
recalculate it once when a source state has changed
If you wrap a source state with a regular function, its value will be recomputed
every time you call that function.
[`derive()`](../../api/reactivity-core.md#derive) accepts a functions whose
return value will be cached, so that subsequent calls of this derived state
will return the same cached value until one of its source states have changed.
```lua
local derive = vide.derive
```
```lua
local count = source(0)
local factorial = derive(function()
local n = 1
for i = 2, count() do
n *= i
end
return n
end)
```
This can improve performance for expensive calculations.
```lua
create "TextLabel" {
Text = function()
return "factorial squared: " .. factorial() * factorial()
end
}
count(3) -- displays "factorial squared: 36"
count(4) -- displays "factorial squared: 576"
```

View file

@ -0,0 +1,35 @@
# Table State
Vide has functions for dealing with table states.
Below is an example using the above `List` class.
```lua
type Item = {
Name: string,
Icon: number
}
local items = source({} :: Array<Item>)
List {
Children = indexes(items, function(item, i)
return create "ImageLabel" {
Image = function()
return "rbxassetid://" .. item().Icon
end,
LayoutOrder = i
}
end)
}
```
Here we map each element in `items` to a value returned by a callback.
The callback is called only *once* per key. The first argument given to the
callback is a state that has the value of the table key's value.
Anytime the value of the corresponding table key changes, the state value
changes too. This saves us from having to recreate a UI element any time a
table index changes.

View file

@ -0,0 +1,98 @@
# Property Groups
Often when creating components from existing components, you can find yourself
repetitively passing through properties such as size or position.
```lua
function Background(props: {
Color: Color3,
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return create "Frame" {
Color = props.Color
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
function Menu(props: {
Color = props.Color
AnchorPoint: UDim2,
Position: UDim2,
Size: UDim2
})
return Background {
Color = props.COlor,
AnchorPoint = props.AnchorPoint,
Position = props.Position,
Size = props.Size
}
end
```
One way this can be avoided is by using *property nesting*. In Vide, passign a
table value inside `props` has special semantics. Any key with a table value is
not assigned like a property, instead the table is iterated and processed just
like the outer table is. Any properties in the nested table will be assigned
to the instance just the same.
Below is an example of how you can use this to pass groups of similar properties
together such as position and size, while also using typechecking.
```lua
type Layout = {
Layout = {
Position: UDim2?,
Size: UDim2?,
AnchorPoint: Vector2?
}
}
function Background(props: Layout & { Color: Color3 })
return create "Frame" {
Color = props.Color,
props.Layout
}
end
function Menu(props: Layout & { Color: Color3 })
return Background {
Color = props.Color,
Layout = props.Layout
}
end
```
Here we created a nested group with the key `Layout` that can accept
layout-related properties. Any name could be chosen for the key.
This allows us to write much more concise syntax that is also typecheckable.
The same can be done for properties such as children to pass table of instances.
```lua
type Children = {
Children = Array<Instance>
}
local function List(props: Children & Layout)
return create "Frame" {
props.Layout,
props.Children,
create "UIListLayout" {}
}
end
List {
Layout = {
Position = UDim2.new()
},
Children = {
create "TextLabel" { Text = "1" },
create "TextLabel" { Text = "2" }
}
}
```

25
src/action.luau Normal file
View file

@ -0,0 +1,25 @@
type Action = {
priority: number,
callback: (Instance) -> ()
}
local ActionMT = {}
local function is_action(v: any)
return getmetatable(v) == ActionMT
end
local function action(callback: (Instance) -> (), priority: number?): Action
local t = {
priority = priority or 1,
callback = callback
}
setmetatable(t :: any, ActionMT)
return t
end
return function()
return action, is_action
end

83
src/apply.luau Normal file
View file

@ -0,0 +1,83 @@
if not game then
script = (require :: any) "test/wrap-require"
typeof = require "test/mock".typeof
end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local throw = require(script.Parent.throw)
local bind = require(script.Parent.bind)
local _, is_action = require(script.Parent.action)()
local event_buffer: { [string]: () -> () } = {}
local action_buffers = {} :: { { () -> () } }
setmetatable(action_buffers :: any, {
__index = function(_, i: number)
action_buffers[i] = {}
return action_buffers[i]
end
})
local function recurse(instance: Instance, properties: { [unknown]: unknown })
for property, value in properties do
if type(value) == "table" then
if is_action(value) then
table.insert(action_buffers[(value :: any).priority], (value :: any).callback :: () -> ())
else
recurse(instance, value :: {})
end
elseif type(property) == "string" then
if type(value) == "function" then
if typeof((instance :: any)[property]) == "RBXScriptSignal" then
event_buffer[property] = value :: () -> ()
else
bind.property(instance, property, value :: () -> ())
end
else
(instance :: any)[property] = value
end
elseif type(property) == "number" then
if type(value) == "function" then
bind.children(instance, value :: () -> { Instance })
else
(value :: Instance).Parent = instance
end
end
end
end
local function apply<T>(instance: T & Instance, properties: { [unknown]: unknown }): T
local parent: unknown = properties.Parent
if parent then properties.Parent = nil end
table.clear(event_buffer)
for _, buffer in next, action_buffers do
table.clear(buffer)
end
recurse(instance, properties)
for event, fn in next, event_buffer do
(instance :: any)[event]:Connect(fn)
end
for _, buffer in next, action_buffers do
for _, callback in next, buffer do
callback()
end
end
if parent then
if type(parent) == "function" then
error("cannot set parent to state")
else
instance.Parent = parent :: Instance
end
end
return instance
end
return apply

125
src/bind.luau Normal file
View file

@ -0,0 +1,125 @@
local warn = warn -- todo
if not game then
script = (require :: any) "test/wrap-require"
warn = print
end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local get = graph.get
local set_effect = graph.set_effect
local capture = graph.capture
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 .. "apply",
srctrunc .. "create",
}
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(instance: Instance, setter: (Instance) -> ())
if flags.strict then
local fn = setter
local trace = traceback()
setter = 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
local nodes = (capture(setter :: () -> unknown, instance))
for _, node in next, nodes do
set_effect(node, setter, instance)
end
bindcount += 1
local key = bindcount
weak[key] = instance
local function ref()
local _ = setter
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
-- todo: move `fn` as arg?
local function bind_property(instance: Instance, property: string, fn: () -> unknown)
setup(instance, function(instance_weak: any)
instance_weak[property] = fn()
end)
end
local function bind_parent(instance: Instance, fn: () -> Instance?)
instance.Destroying:Connect(function()
instance= nil :: any -- allow gc when destroyed
end)
setup(instance, function(instance)
local _ = instance -- state will strongly reference instance when parent is bound
instance.Parent = fn()
end)
end
-- todo: could optimize, see: maps.luau values()
local function bind_children(parent: Instance, fn: () -> { Instance })
local current_child_set: { [Instance]: true } = {} -- cache of all children parented before update
local new_child_set: { [Instance]: true } = {} -- cache of all children parented after update
setup(parent, function(parent_weak)
local new_childs = fn() -- all (and only) children that should be parented after this update
if new_childs and type(new_childs) ~= "table" then
throw(`Cannot parent instance of type { type(new_childs) } `)
end
if new_childs then
for _, child in next, new_childs do
new_child_set[child] = true -- record child set from this update
if not current_child_set[child] then
child.Parent = parent_weak -- if child wasn't already parented then parent it
else
current_child_set[child] = nil -- remove child from cache if it was already in cache
end
end
end
for child in next, current_child_set do
child.Parent = nil -- unparent all children that weren't in the new children set
end
table.clear(current_child_set) -- clear cache, preserve capacity
current_child_set, new_child_set = new_child_set, current_child_set
end)
end
return {
property = bind_property,
parent = bind_parent,
children = bind_children,
}

42
src/cleanup.luau Normal file
View file

@ -0,0 +1,42 @@
if not game then script = require "test/wrap-require" end
-- todo: verify correct behavior in non-standard usage
local cleanup_callbacks = {} :: { [string]: () -> () }
local cleanup_callers = {} :: { [string]: () -> () }
setmetatable(cleanup_callers :: any, { __mode = "vs" })
-- todo: rare case where mem address is reused by another function on same line
local function cleanup(callback: () -> ())
local caller = debug.info(2, "f") :: () -> ()
local line = debug.info(2, "l") :: number
local ref = tostring(caller) .. "\0" .. line
local fn = cleanup_callbacks[ref]
if fn then
fn()
else
cleanup_callers[ref] = caller
end
cleanup_callbacks[ref] = callback
end
local buffer = {}
local function clean_garbage()
for ref, callback in next, cleanup_callbacks do
if cleanup_callers[ref] == nil then -- caller was garbage collected
callback()
table.insert(buffer, ref)
end
end
for _, ref in next, buffer do
cleanup_callbacks[ref] = nil
end
table.clear(buffer)
end
return function() return cleanup, clean_garbage end

75
src/create.luau Normal file
View file

@ -0,0 +1,75 @@
if not game then
script = (require :: any) "test/wrap-require"
Instance = require("test/mock").Instance
typeof = require("test/mock").typeof
end
local throw = require(script.Parent.throw)
local defaults = require(script.Parent.defaults)
local apply = require(script.Parent.apply)
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 apply(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 apply(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 )

109
src/defaults.luau Normal file
View file

@ -0,0 +1,109 @@
-- 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
}
}

16
src/derive.luau Normal file
View file

@ -0,0 +1,16 @@
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
local create = graph.create
local get = graph.get
local capture_and_link = graph.capture_and_link
local function derive<T>(fn: () -> T): () -> T
local node, node_get = create((nil :: any) :: T)
node.cache = capture_and_link(node, fn)
return node_get
end
return derive

1
src/flags.luau Normal file
View file

@ -0,0 +1 @@
return { strict = false }

151
src/graph.luau Normal file
View file

@ -0,0 +1,151 @@
if not game then script = (require :: any) "test/wrap-require" end
local flags = require(script.Parent.flags)
export type Node<T> = {
cache: T,
derive: () -> T,
effects: { [(unknown) -> ()]: unknown }, -- weak values
children: { Node<T> } | false -- weak values
}
local reff = false
local refs = {} :: { Node<unknown> }
local WEAK_VALUES_RESIZABLE = { __mode = "vs" }
local EVALUATION_ERR = "error while evaluating node:\n\n"
setmetatable(refs :: any, WEAK_VALUES_RESIZABLE)
local check_for_yield do
local t = { __mode = "kv" }
setmetatable(t, t)
check_for_yield = function<T..., U...>(fn: (T...) -> (), ...: any)
local args = { ... }
t.__unm = function()
fn(unpack(args))
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 node in watcher", 3)
else
error(EVALUATION_ERR..err, 3)
end
end
end
end
local function set_effect<T>(node: Node<unknown>, fn: (T) -> (), key: T)
node.effects[fn :: () -> ()] = key
end
local function run_effects(node: Node<unknown>)
for effect, key in next, node.effects do
if flags.strict then effect(key) end
effect(key)
end
end
-- retrieves a node's cached value
-- recalculates value if an ancestor was updated
local function get<T>(node: Node<T>): T
if reff then table.insert(refs, node) end
return node.cache
end
local function set_child(parent: Node<unknown>, child: Node<unknown>)
if parent.children then
table.insert(parent.children, child)
else
parent.children = { child }
setmetatable(parent.children :: any, WEAK_VALUES_RESIZABLE)
end
end
-- runs node effects, recalculates descendants and runs descendant effects
local function update(node: Node<unknown>)
run_effects(node)
if node.children then
for _, child in node.children do
if flags.strict then check_for_yield(child.derive) end
child.cache = child.derive()
update(child)
end
end
end
-- sets a node's cached value and updates all descendants
local function set<T>(node: Node<T>, value: T)
node.cache = value
update(node)
end
-- links two nodes as parent-child with a function to compute a new value for child
local function link<T>(parent: Node<unknown>, child: Node<T>, derive: () -> T)
child.derive = derive
set_child(parent, child)
end
-- detect what nodes were referenced in the given callback and returns them in an array
local function capture<T, U>(fn: (U?) -> T, arg: U?): ({ Node<unknown> }, T)
if flags.strict then check_for_yield(fn, arg) end
table.clear(refs)
reff = true
local ok: boolean, result: T|string
if arg == nil then
ok, result = pcall(fn)
else
ok, result = pcall(fn, arg)
end
reff = false
if not ok then error("error while detecting watcher: " .. result :: string, 0) end
return refs, result :: T
end
-- captures and links any detected nodes
local function capture_and_link<T>(child: Node<T>, fn: () -> T): T
local nodes, value = capture(fn, nil)
child.derive = fn
for _, parent: Node<unknown> in next, nodes do
set_child(parent, child)
end
return value :: T
end
local function create<T>(value: T): (Node<T>, () -> T)
local node = {
cache = value,
derive = function() return nil :: any end,
effects = setmetatable({}, WEAK_VALUES_RESIZABLE) :: any,
children = false :: false
}
local function get_value()
return get(node)
end
return node, get_value
end
return table.freeze {
set_effect = set_effect,
get = get,
set = set,
link = link,
capture = capture,
capture_and_link = capture_and_link,
create = create :: (<T>(value: T) -> (Node<T>, () -> T)) & (<T>() -> (Node<T>, () -> T)),
}

71
src/init.luau Normal file
View file

@ -0,0 +1,71 @@
--------------------------------------------------------------------------------
-- vide.luau
-- v0.1.0
--------------------------------------------------------------------------------
if not game then script = (require :: any) "test/wrap-require" end
local create = require(script.create)
local source = require(script.source)
local watch = require(script.watch)
local cleanup, clean_garbage = require(script.cleanup)()
local derive = require(script.derive)
local indexes, values = require(script.maps)()
local spring, update_springs = require(script.spring)()
local action = require(script.action)()
local flags = require(script.flags)
local vide = {
-- core
create = create,
source = source,
watch = watch,
cleanup = cleanup,
derive = derive,
indexes = indexes,
values = values,
-- animations
spring = spring,
-- actions
action = action,
-- flags
strict = (nil :: any) :: boolean,
-- runtime
step = function(dt: number)
-- debug.profilebegin("VIDE STEP")
-- debug.profilebegin("VIDE SPRING")
update_springs(dt)
-- debug.profileend()
-- debug.profilebegin("VIDE GARBAGE CLEANUP")
clean_garbage()
-- debug.profileend()
-- debug.profileend()
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

145
src/maps.luau Normal file
View file

@ -0,0 +1,145 @@
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
local capture = graph.capture
local link = graph.link
type Map<K, V> = { [K]: V }
-- todo: optimize output array
local function indexes<K, VI, VO>(input: () -> Map<K, VI>, transform: (() -> VI, K) -> VO): () -> { VO }
local input_cache = {} :: Map<K, VI>
local output_cache = {} :: Map<K, VO>
local input_nodes = {} :: Map<K, Node<VI>>
local remove_queue = {} :: { K }
local output_array = {} :: { VO }
local function recompute(data)
-- queue removed values
for k in next, input_cache do
if data[k] == nil then
table.insert(remove_queue, k)
end
end
-- remove queued values
for _, k in next, remove_queue do
input_cache[k] = nil
output_cache[k] = nil
input_nodes[k] = nil
end
table.clear(remove_queue)
-- process new or changed values
for k, v in next, data do
local cv = input_cache[k]
if cv == nil then
local node, get_value = create(v)
input_nodes[k] = node
output_cache[k] = transform(get_value, k)
input_cache[k] = v
elseif cv ~= v then
set(input_nodes[k], v)
input_cache[k] = v
end
end
-- output elements
table.clear(output_array)
for _, v in next, output_cache do
table.insert(output_array, v)
end
return output_array
end
local function derive()
return recompute(input())
end
local output, output_get = create(nil :: any)
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
output.cache = recompute(value)
return output_get
end
-- todo: optimize output array
local function values<K, VI, VO>(input: () -> Map<K, VI>, transform: (VI, () -> K) -> VO): () -> { VO }
local cur_input_cache_up = {} :: Map<VI, K>
local new_input_cache_up = {} :: Map<VI, K>
local output_cache = {} :: Map<VI, VO>
local input_nodes = {} :: Map<VI, Node<K>>
local output_array = {} :: { VO }
local function recompute(data: Map<K, VI>)
local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up
-- process data
for i, v in next, data do
new_input_cache[v] = i
local cv = cur_input_cache[v]
if cv == nil then
local node, get_value = create(i)
input_nodes[v] = node
output_cache[v] = transform(v, get_value)
else
if cv ~= i then
set(input_nodes[v], i)
end
cur_input_cache[v] = nil
end
end
-- remove old values
for v in next, cur_input_cache do
output_cache[v] = nil
input_nodes[v] = nil
end
-- update buffer cache
table.clear(cur_input_cache)
cur_input_cache_up, new_input_cache_up = new_input_cache, cur_input_cache
-- output elements
table.clear(output_array)
for _, v in next, output_cache do
table.insert(output_array, v)
end
return output_array
end
local function derive()
return recompute(input())
end
local output, output_get = create(nil :: any)
local nodes, value = capture(input)
for _, node in next, nodes do
link(node, output, derive)
end
output.cache = recompute(value)
return output_get
end
return function() return indexes, values end

17
src/memoize.luau Normal file
View file

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

25
src/source.luau Normal file
View file

@ -0,0 +1,25 @@
if not game then script = require "test/wrap-require" end
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local set = graph.set
type Source<T> = (() -> T) & ((T) -> T)
local function source<T>(value: T): Source<T>
local node, get_value = create(value :: T)
return function(...): T
if select("#", ...) == 0 then return get_value() end
local v = ... :: T
if node.cache == v and type(v) ~= "table" then return v end
set(node, v)
return v
end
end
return source :: (<T>(value: T) -> Source<T>) & (<T>() -> Source<T>)

195
src/spring.luau Normal file
View file

@ -0,0 +1,195 @@
if not game then script = (require :: any) "test/wrap-require" end
--[[
Supported datatypes:
- number
- CFrame
- Color3
- UDim
- UDim2
- Vector2
- Vector3
Unsupported datatypes:
- bool
- Rect
- Vector2int16
- Vector3int16
- EnumItem
]]
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
local create = graph.create
local get = graph.get
local set = graph.set
local set_effect = graph.set_effect
local capture = graph.capture
type Node<T> = graph.Node<T>
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
alpha: number,
duration: number,
period: number,
damping_ratio: number,
velocity: number,
initial_velocity: number,
initial_position: T,
target_position: T,
target_updated: boolean,
target: () -> 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 springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" })
local function spring<T>(target: () -> T, period: number?, damping_ratio: number?): () -> T
local inputs, initial_position = capture(target)
local output, output_get = create(initial_position)
local data: SpringData<T> = {
alpha = 0,
duration = 0,
period = period or 1,
damping_ratio = damping_ratio or 1,
velocity = 0,
initial_velocity = 0,
initial_position = initial_position,
target_position = initial_position,
target_updated = false,
target = target
}
local function input_changed(node)
data.target_updated = true
data.target_position = target()
springs[data] = node
end
for _, input in next, inputs do
set_effect(input, input_changed, output)
end
springs[data] = output
return output_get
end
local remove_queue = {}
local function update_springs(dt: number)
for data, output in next, springs do
if data.target_updated then
data.target_updated = false
data.target_position = data.target()
data.initial_position = get(output)
data.alpha = 0
data.duration = 0
data.initial_velocity = data.velocity
end
local initial_position = data.initial_position
local target_position = data.target_position
local target_type = typeof(target_position)
if target_type ~= typeof(initial_position) then
springs[data] = nil
warn(string.format(
"Mismatched state value types, cancelling state update (initial value: %s, target value: %s)",
typeof(initial_position),
target_type
))
throw(`Cannot tween type { typeof(initial_position) } and { target_type }`)
continue
end
local lerp: Lerp<Animatable> = lerpable[target_type]
if lerp == nil then
springs[data] = nil
throw(`Cannot animate type { target_type }`)
continue
end
local new_time = data.duration + dt
local new_alpha = solve(data.period, data.damping_ratio, data.initial_velocity, new_time)
local new_velocity = -(new_alpha - data.alpha)/dt
local acceleration = (new_velocity - data.velocity)/dt
data.velocity = new_velocity
data.alpha = new_alpha
data.duration = new_time
local value = lerp(initial_position, target_position, new_alpha)
if math.abs(acceleration) < 0.01 then
table.insert(remove_queue, data)
set(output, target_position)
else
set(output, value)
end
end
for _, data in next, remove_queue do
springs[data] = nil
end
table.clear(remove_queue)
end
return function() return spring, update_springs end

11
src/throw.luau Normal file
View file

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

25
src/watch.luau Normal file
View file

@ -0,0 +1,25 @@
if not game then script = (require :: any) "test/wrap-require" end
local graph = require(script.Parent.graph)
local set_effect = graph.set_effect
local capture = graph.capture
local function watch(effect: () -> ()): () -> ()
local nodes = capture(effect :: () -> nil)
nodes = table.clone(nodes)
for _, node in next, nodes do
set_effect(node, effect, true)
end
local function unwatch()
for _, node in next, nodes do
set_effect(node, effect, nil)
end
end
return unwatch
end
return watch

254
test/benchmark.luau Normal file
View file

@ -0,0 +1,254 @@
------------------------------------------------------------------------------------------
-- benchmark.lua
------------------------------------------------------------------------------------------
local BENCH, START = require("test/testkit").benchmark()
local vide = require "src/init"
local N = 2^18 -- 262144
BENCH("Create state", function()
local cache = table.create(N)
local source = vide.source
for i = 1, START(N) do
cache[i] = source(1)
end
end)
BENCH("Get value", function()
local state = vide.source(1)
for i = 1, START(N) do
state()
end
end)
BENCH("Set value", function()
local state = vide.source(1)
for i = 1, START(N) do
state(i)
end
end)
BENCH("Derive 1 state", function()
local cache = table.create(N)
local state = vide.source(1)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function()
return state()
end)
end
end)
BENCH("Derive 4 states", function()
local cache = table.create(N)
local state = vide.source(1)
local state2 = vide.source(2)
local state3 = vide.source(3)
local state4 = vide.source(4)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function()
return state() + state2() + state3() + state4()
end)
end
end)
BENCH("Set derived value", function()
local state = vide.source(1)
local _derived = vide.derive(state)
for i = 1, START(N) do
state(i)
end
end)
BENCH("Apply 0 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
for i = 1, START(N) do
apply(instance, {})
end
end)
BENCH("Apply 8 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
for i = 1, START(N) do
apply(instance, {
Name = i,
Name2 = i,
Name3 = i,
Name4 = i,
Name5 = i,
Name6 = i,
Name7 = i,
Name8 = i,
})
end
end)
BENCH("Bind state", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
for i = 1, START(N) do
apply(instance, {
Name = state
})
end
end)
BENCH("Update binding", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
apply(instance, {
Name = state
})
for i = 1, START(N) do
state(i)
end
end)
BENCH("indexes() no change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
START(N)
state(data)
end)
BENCH("indexes() all change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
for i, v in data do
data[i] = v + 1
end
START(N)
state(data)
end)
BENCH("indexes() all remove", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
table.clear(data)
START(N)
state(data)
end)
BENCH("values() no change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
state(state()) -- fill double buffer
START(N)
state(data)
end)
BENCH("values() all change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
state(state()) -- fill double buffer
for i = 1, N do
local r = math.random(1, #data)
data[i], data[r] = data[r], data[i]
end
START(N)
state(data)
end)
BENCH("values() all remove", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
table.clear(data)
START(N)
state(data)
end)
return nil

184
test/goodsignal.luau Normal file
View file

@ -0,0 +1,184 @@
--!nocheck
-- modified 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 }
export type Type = RBXScriptSignal & { Fire: (Type, ...any)-> () }
----------------------------------------------------------------------------------------------------
-- 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
Signal.__type = "RBXScriptSignal"
function Signal.new(): Type
return setmetatable({
_handlerListHead = false,
}, Signal) :: any
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
return Signal

254
test/mock.luau Normal file
View file

@ -0,0 +1,254 @@
local Instance = {} do
local Signal = require "test/goodsignal"
type Signal = Signal.Type
type userdata = { __USERDATA: true }
--[[
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
proxy knows data
data does not know proxy
separate weak map kept data -> proxy
]]
type ProxyMT = {
proxy: userdata,
data: Data,
__index: any,
__newindex: any
}
type Data = {
name: string,
parent: Data?,
children: { Data },
changed: { [string]: Signal },
properties: { [string]: unknown },
destroying: Signal,
class: string,
type: "Instance"
}
local function deep_clone<T>(template: T & {}): T
local t = table.clone(template :: {}) :: {}
for i, v in next, t do
if type(v) == "table" then
t[i] = deep_clone(v)
end
end
return t :: T & {}
end
local proxies = {} :: { [Data]: userdata? }
setmetatable(proxies :: any, { __mode = "v" })
local function get_data(userdata: userdata): Data
local function f(userdata: userdata): ProxyMT
return getmetatable(userdata :: any)
end
return f(userdata).data
end
local function is_instance(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 = get_data(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.properties[property]
end
local function __newindex(userdata: userdata, property: string, value: unknown)
local data = get_data(userdata)
if property == "Name" then
data.name = value :: string
elseif property == "Parent" then
assert(value == nil or is_instance(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 = get_data(value :: userdata)
table.insert(get_data(value :: userdata).children, data)
end
else
data.properties[property] = value
end
if data.changed[property] then
data.changed[property]:Fire()
end
end
local function get_proxy(data: Data): userdata
return proxies[data] or (function()
local userdata = newproxy(true)
local proxy = getmetatable(userdata)
proxy.proxy = 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 = {},
properties = {},
class = class,
destroying = Signal.new() :: any,
type = "Instance" :: "Instance"
}
return get_proxy(data) :: any
end
function Instance.is_instance(value: unknown): boolean
return is_instance(value)
end
function methods.Clone(userdata: userdata): userdata
local data = get_data(userdata)
local clone_userdata = (Instance.new("") :: any) :: userdata
local clone_data = get_data(clone_userdata)
for i, v in next, deep_clone(data) do
clone_data[i] = v
end
return clone_userdata
end
function methods.FindFirstChild(userdata: userdata, target: string): userdata?
local data = get_data(userdata)
for _, child in data.children do
if child.name == target then
return get_proxy(child)
end
end
return nil
end
function methods.GetChildren(userdata: userdata): { userdata }
local children = get_data(userdata).children
local userdatas = table.create(#children)
for i, child in next, children do
userdatas[i] = get_proxy(child)
end
return userdatas
end
function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal
local data = get_data(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 = get_data(userdata);
data.destroying: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): string
return if Instance.is_instance(v) then "Instance"
elseif getmetatable(v) and getmetatable(v).__type then getmetatable(v).__type
else type(v)
end
return {
Instance = Instance,
Color3 = Color3,
Vector3 = Vector3,
Vector2 = Vector2,
UDim2 = UDim2,
Enum = Enum,
typeof = typeof
}

460
test/testkit.luau Normal file
View file

@ -0,0 +1,460 @@
--------------------------------------------------------------------------------
-- testkit.luau
-- v0.7.0
--------------------------------------------------------------------------------
local color = {
white_underline = function(s: string)
return `\27[1;4m{s}\27[0m`
end,
white = function(s: string)
return `\27[37;1m{s}\27[0m`
end,
green = function(s: string)
return `\27[32;1m{s}\27[0m`
end,
red = function(s: string)
return `\27[31;1m{s}\27[0m`
end,
yellow = function(s: string)
return `\27[33;1m{s}\27[0m`
end,
red_highlight = function(s: string)
return `\27[41;1;30m{s}\27[0m`
end,
green_highlight = function(s: string)
return `\27[42;1;30m{s}\27[0m`
end,
gray = function(s: string)
return `\27[30;1m{s}\27[0m`
end,
}
local function convert_units(unit: string, value: number): (number, string)
local prefix_colors = {
[3] = color.red,
[2] = color.yellow,
[1] = color.yellow,
[0] = color.green,
[-1] = color.red,
[-2] = color.yellow,
[-3] = color.green
}
local prefixes = {
[3] ="G",
[2] ="M",
[1] = "k",
[0] = " ",
[-1] = "m",
[-2] = "u",
[-3] = "n"
}
local order = 0
while value >= 1000 do
order += 1
value /= 1000
end
while value ~= 0 and value < 1 do
order -= 1
value *= 1000
end
if value >= 100 then
value = math.floor(value)
elseif value >= 10 then
value = math.floor(value * 1e1) / 1e1
elseif value >= 1 then
value = math.floor(value * 1e2) / 1e2
end
return value, prefix_colors[order](prefixes[order] .. unit)
end
local WALL = color.gray ""
--------------------------------------------------------------------------------
-- Testing
--------------------------------------------------------------------------------
type Test = {
name: string,
case: Case?,
cases: { Case },
duration: number,
error: {
message: string,
trace: string
}?
}
type Case = {
name: string,
result: number,
line: number?
}
local PASS, FAIL, NONE, ERROR = 1, 2, 3, 4
local skip: string?
local test: Test?
local tests: { Test } = {}
local function output_test_result(test: Test)
print(color.white(test.name))
for _, case in test.cases do
local status = ({
[PASS] = color.green "PASS",
[FAIL] = color.red "FAIL",
[NONE] = color.yellow "NONE",
[ERROR] = color.red "FAIL"
})[case.result]
local line = case.result == FAIL and color.red(`{case.line}:`) or ""
print(`{status}{WALL} {line}{color.gray(case.name)}`)
end
if test.error then
print(color.gray "error: " .. color.red(test.error.message))
print(color.gray "trace: " .. color.red(test.error.trace))
else
print()
end
end
local function CASE(name: string)
assert(test, "no active test")
local case = {
name = name,
result = NONE
}
test.case = case
table.insert(test.cases, case)
end
local function CHECK<T>(value: T, stack: number?): T
assert(test, "no active test")
local case = test.case
if not case then
CASE ""
case = test.case
end
assert(case, "no active case")
if case.result ~= FAIL then
case.result = value and PASS or FAIL
case.line = debug.info(stack and stack + 1 or 2, "l")
end
return value
end
local function TEST(name: string, fn: () -> ())
if skip and name ~= skip then return end
local active = test
assert(not active, "cannot start test while another test is in progress")
test = {
name = name,
cases = {},
duration = 0
}; assert(test)
table.insert(tests, test)
local start = os.clock()
local err
local success = xpcall(fn, function(m: string)
err = { message = m, trace = debug.traceback(nil, 2) }
end)
test.duration = os.clock() - start
if not test.case then CASE "" end
assert(test.case, "no active case")
if not success then
test.case.result = ERROR
test.error = err
end
test = nil
end
local function FINISH(): boolean
local success = true
local total_cases = 0
local passed_cases = 0
local duration = 0
for _, test in tests do
duration += test.duration
for _, case in test.cases do
total_cases += 1
if case.result == PASS or case.result == NONE then
passed_cases += 1
else
success = false
end
end
output_test_result(test)
end
print(color.gray(string.format(
`{passed_cases}/{total_cases} test cases passed in %.3f ms.`,
duration*1e3
)))
local fails = total_cases - passed_cases
print(
(
fails > 0
and color.red
or color.green
)(`{fails} {fails == 1 and "fail" or "fails"}`)
)
return success, table.clear(tests)
end
local function SKIP(name: string)
assert(not test, "cannot skip during test")
skip = name
end
--------------------------------------------------------------------------------
-- Benchmarking
--------------------------------------------------------------------------------
type Bench = {
time_start: number?,
memory_start: number?,
iterations: number?
}
local bench: Bench?
function START(iter: number?): number
local n = iter or 1
assert(n > 0, "iterations must be greater than 0")
assert(bench, "no active benchmark")
assert(not bench.time_start, "clock was already started")
bench.iterations = n
bench.memory_start = gcinfo()
bench.time_start = os.clock()
return n
end
local function BENCH(name: string, fn: () -> ())
local active = bench
assert(not active, "a benchmark is already in progress")
bench = {}; assert(bench)
;(collectgarbage :: any)("collect")
local mem_start = gcinfo()
local time_start = os.clock()
local err_msg: string?
local success = xpcall(fn, function(m: string)
err_msg = m .. debug.traceback(nil, 2)
end)
local time_stop = os.clock()
local mem_stop = gcinfo()
if not success then
print(`{WALL}{color.red("ERROR")}{WALL} {name}`)
print(color.gray(err_msg :: string))
else
time_start = bench.time_start or time_start
mem_start = bench.memory_start or mem_start
local n = bench.iterations or 1
local d, d_unit = convert_units("s", (time_stop - time_start) / n)
local a, a_unit = convert_units("B", math.floor((mem_stop - mem_start) / n * 1e3))
local function round(x: number): string
return x > 0 and x < 10 and (x - math.floor(x)) > 0
and string.format("%2.1f", x)
or string.format("%3.f", x)
end
print(string.format(
`%s %s %s %s{WALL} %s`,
color.gray(tostring(round(d))),
d_unit,
color.gray(tostring(round(a))),
a_unit,
color.gray(name)
))
end
bench = nil
end
--------------------------------------------------------------------------------
-- Printing
--------------------------------------------------------------------------------
local function print2(v: unknown)
type Buffer = { n: number, [number]: string }
type Cyclic = { [{}]: true }
-- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic)
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 -- is table
local tabbed_indent = indent .. TAB
str.n += 1
if cyclic[value] then
str[str.n] = color.gray "*cyclic reference*"
return
else
cyclic[value] = true
end
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, cyclic)
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 }
local cyclic = {}
tos(v, 0, str, cyclic)
print(table.concat(str))
end
--------------------------------------------------------------------------------
-- Equality
--------------------------------------------------------------------------------
local function shallow_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
for i, v in next, a do
if b[i] ~= v then
return false
end
end
for i, v in next, b do
if a[i] ~= v then
return false
end
end
return true
end
local function deep_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
for i, v in next, a do
if type(b[i]) == "table" and type(v) == "table" then
if deep_eq(b[i], v) == false then return false end
elseif b[i] ~= v then
return false
end
end
for i, v in next, b do
if type(a[i]) == "table" and type(v) == "table" then
if deep_eq(a[i], v) == false then return false end
elseif a[i] ~= v then
return false
end
end
return true
end
--------------------------------------------------------------------------------
-- Return
--------------------------------------------------------------------------------
return {
test = function()
return TEST, CASE, CHECK, FINISH, SKIP
end,
benchmark = function()
return BENCH, START
end,
print2 = print2,
seq = shallow_eq,
deq = deep_eq,
color = color
}

1302
test/tests.luau Normal file

File diff suppressed because it is too large Load diff

8
test/wrap-require.luau 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

18
todo.md Normal file
View file

@ -0,0 +1,18 @@
# todo
- cleanup codebase
- setup site, improve docs
- cleanup within `values()` and `indexes()`
- address behavior of binding property to multiples states
- strict mode
- better error reporting
- warn when `values()` returns primitive
- warn when `values()` returns duplicate object
- implement from solid
- [x] onCleanup > `cleanup()`
- [x] Index > `indexes()`
- [x] For > `values()`
- [ ] untrack
- [ ] batch
- [ ] async/resource/loading/suspense
- define order with nested properties