mirror of
https://github.com/centau/vide.git
synced 2026-08-20 14:41:37 +00:00
Initial commit
This commit is contained in:
commit
cb002f4f27
50 changed files with 4666 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.luau linguist-language=Lua
|
||||||
55
.github/workflows/deploy.yml
vendored
Normal file
55
.github/workflows/deploy.yml
vendored
Normal 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
25
.github/workflows/unit-test.yml
vendored
Normal 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
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.vscode
|
||||||
|
_local
|
||||||
|
|
||||||
|
aftman.toml
|
||||||
|
sourcemap.json
|
||||||
|
|
||||||
|
docs/.vitepress/dist
|
||||||
|
docs/.vitepress/cache
|
||||||
6
.luaurc
Normal file
6
.luaurc
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"languageMode": "strict",
|
||||||
|
"lint": { "BuiltinGlobalWrite": false, "UnknownGlobal": false },
|
||||||
|
"globals": [ "Instance" ]
|
||||||
|
}
|
||||||
|
|
||||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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
21
LICENSE.md
Normal 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
35
README.md
Normal 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
7
aftman.toml
Normal 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
4
default.project.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"name": "vide",
|
||||||
|
"tree": { "$path": "src" }
|
||||||
|
}
|
||||||
57
docs/.vitepress/config.ts
Normal file
57
docs/.vitepress/config.ts
Normal 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" }
|
||||||
|
// ]
|
||||||
|
}
|
||||||
|
})
|
||||||
4
docs/.vitepress/theme/index.js
Normal file
4
docs/.vitepress/theme/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// .vitepress/theme/index.js
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import './vars.css'
|
||||||
|
export default DefaultTheme
|
||||||
7
docs/.vitepress/theme/vars.css
Normal file
7
docs/.vitepress/theme/vars.css
Normal 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
37
docs/api/animation.md
Normal 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
79
docs/api/creation.md
Normal 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
250
docs/api/reactivity-core.md
Normal 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.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
25
docs/api/reactivity-utility.md
Normal file
25
docs/api/reactivity-utility.md
Normal 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
25
docs/api/strict-mode.md
Normal 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
21
docs/index.md
Normal 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
13
docs/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
docs/tut/crash-course/1-introduction.md
Normal file
23
docs/tut/crash-course/1-introduction.md
Normal 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.
|
||||||
60
docs/tut/crash-course/2-creation.md
Normal file
60
docs/tut/crash-course/2-creation.md
Normal 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
|
||||||
47
docs/tut/crash-course/3-components.md
Normal file
47
docs/tut/crash-course/3-components.md
Normal 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.
|
||||||
56
docs/tut/crash-course/4-state.md
Normal file
56
docs/tut/crash-course/4-state.md
Normal 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.
|
||||||
58
docs/tut/crash-course/5-derived-state.md
Normal file
58
docs/tut/crash-course/5-derived-state.md
Normal 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"
|
||||||
|
```
|
||||||
35
docs/tut/crash-course/6-table-state.md
Normal file
35
docs/tut/crash-course/6-table-state.md
Normal 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.
|
||||||
98
docs/tut/crash-course/7-property-groups.md
Normal file
98
docs/tut/crash-course/7-property-groups.md
Normal 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
25
src/action.luau
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
type Action = {
|
||||||
|
priority: number,
|
||||||
|
callback: (Instance) -> ()
|
||||||
|
}
|
||||||
|
|
||||||
|
local ActionMT = {}
|
||||||
|
|
||||||
|
local function is_action(v: any)
|
||||||
|
return getmetatable(v) == ActionMT
|
||||||
|
end
|
||||||
|
|
||||||
|
local function action(callback: (Instance) -> (), priority: number?): Action
|
||||||
|
local t = {
|
||||||
|
priority = priority or 1,
|
||||||
|
callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(t :: any, ActionMT)
|
||||||
|
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
return function()
|
||||||
|
return action, is_action
|
||||||
|
end
|
||||||
83
src/apply.luau
Normal file
83
src/apply.luau
Normal 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
125
src/bind.luau
Normal 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
42
src/cleanup.luau
Normal 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
75
src/create.luau
Normal 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
109
src/defaults.luau
Normal 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
16
src/derive.luau
Normal 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
1
src/flags.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
return { strict = false }
|
||||||
151
src/graph.luau
Normal file
151
src/graph.luau
Normal 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
71
src/init.luau
Normal 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
145
src/maps.luau
Normal 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
17
src/memoize.luau
Normal 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
25
src/source.luau
Normal 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
195
src/spring.luau
Normal 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
11
src/throw.luau
Normal 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
25
src/watch.luau
Normal 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
254
test/benchmark.luau
Normal 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
184
test/goodsignal.luau
Normal 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
254
test/mock.luau
Normal 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
460
test/testkit.luau
Normal 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
1302
test/tests.luau
Normal file
File diff suppressed because it is too large
Load diff
8
test/wrap-require.luau
Normal file
8
test/wrap-require.luau
Normal 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
18
todo.md
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue