From cb002f4f27071401e4aa4d003517e2d014798331 Mon Sep 17 00:00:00 2001 From: aaron <83140718+centau@users.noreply.github.com> Date: Tue, 8 Aug 2023 21:33:11 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 1 + .github/workflows/deploy.yml | 55 + .github/workflows/unit-test.yml | 25 + .gitignore | 8 + .luaurc | 6 + CHANGELOG.md | 13 + LICENSE.md | 21 + README.md | 35 + aftman.toml | 7 + default.project.json | 4 + docs/.vitepress/config.ts | 57 + docs/.vitepress/theme/index.js | 4 + docs/.vitepress/theme/vars.css | 7 + docs/api/animation.md | 37 + docs/api/creation.md | 79 ++ docs/api/reactivity-core.md | 250 ++++ docs/api/reactivity-utility.md | 25 + docs/api/strict-mode.md | 25 + docs/index.md | 21 + docs/package.json | 13 + docs/tut/crash-course/1-introduction.md | 23 + docs/tut/crash-course/2-creation.md | 60 + docs/tut/crash-course/3-components.md | 47 + docs/tut/crash-course/4-state.md | 56 + docs/tut/crash-course/5-derived-state.md | 58 + docs/tut/crash-course/6-table-state.md | 35 + docs/tut/crash-course/7-property-groups.md | 98 ++ src/action.luau | 25 + src/apply.luau | 83 ++ src/bind.luau | 125 ++ src/cleanup.luau | 42 + src/create.luau | 75 ++ src/defaults.luau | 109 ++ src/derive.luau | 16 + src/flags.luau | 1 + src/graph.luau | 151 +++ src/init.luau | 71 ++ src/maps.luau | 145 +++ src/memoize.luau | 17 + src/source.luau | 25 + src/spring.luau | 195 +++ src/throw.luau | 11 + src/watch.luau | 25 + test/benchmark.luau | 254 ++++ test/goodsignal.luau | 184 +++ test/mock.luau | 254 ++++ test/testkit.luau | 460 +++++++ test/tests.luau | 1302 ++++++++++++++++++++ test/wrap-require.luau | 8 + todo.md | 18 + 50 files changed, 4666 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/unit-test.yml create mode 100644 .gitignore create mode 100644 .luaurc create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 aftman.toml create mode 100644 default.project.json create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/.vitepress/theme/vars.css create mode 100644 docs/api/animation.md create mode 100644 docs/api/creation.md create mode 100644 docs/api/reactivity-core.md create mode 100644 docs/api/reactivity-utility.md create mode 100644 docs/api/strict-mode.md create mode 100644 docs/index.md create mode 100644 docs/package.json create mode 100644 docs/tut/crash-course/1-introduction.md create mode 100644 docs/tut/crash-course/2-creation.md create mode 100644 docs/tut/crash-course/3-components.md create mode 100644 docs/tut/crash-course/4-state.md create mode 100644 docs/tut/crash-course/5-derived-state.md create mode 100644 docs/tut/crash-course/6-table-state.md create mode 100644 docs/tut/crash-course/7-property-groups.md create mode 100644 src/action.luau create mode 100644 src/apply.luau create mode 100644 src/bind.luau create mode 100644 src/cleanup.luau create mode 100644 src/create.luau create mode 100644 src/defaults.luau create mode 100644 src/derive.luau create mode 100644 src/flags.luau create mode 100644 src/graph.luau create mode 100644 src/init.luau create mode 100644 src/maps.luau create mode 100644 src/memoize.luau create mode 100644 src/source.luau create mode 100644 src/spring.luau create mode 100644 src/throw.luau create mode 100644 src/watch.luau create mode 100644 test/benchmark.luau create mode 100644 test/goodsignal.luau create mode 100644 test/mock.luau create mode 100644 test/testkit.luau create mode 100644 test/tests.luau create mode 100644 test/wrap-require.luau create mode 100644 todo.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..876fbd3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.luau linguist-language=Lua diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..10f047c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..a6cd4dc --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..835f5cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.vscode +_local + +aftman.toml +sourcemap.json + +docs/.vitepress/dist +docs/.vitepress/cache diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..16f71db --- /dev/null +++ b/.luaurc @@ -0,0 +1,6 @@ +{ + "languageMode": "strict", + "lint": { "BuiltinGlobalWrite": false, "UnknownGlobal": false }, + "globals": [ "Instance" ] +} + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..21d7d4d --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..57a41d1 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0be7cf3 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..a3d7a6d --- /dev/null +++ b/aftman.toml @@ -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" \ No newline at end of file diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..ce9aa38 --- /dev/null +++ b/default.project.json @@ -0,0 +1,4 @@ +{ + "name": "vide", + "tree": { "$path": "src" } +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..d0fc4bb --- /dev/null +++ b/docs/.vitepress/config.ts @@ -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" } + // ] + } +}) diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 0000000..b8b9aa6 --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,4 @@ +// .vitepress/theme/index.js +import DefaultTheme from 'vitepress/theme' +import './vars.css' +export default DefaultTheme diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css new file mode 100644 index 0000000..08d75e6 --- /dev/null +++ b/docs/.vitepress/theme/vars.css @@ -0,0 +1,7 @@ +:root { + --vp-c-brand: #3086ff; + + --vp-c-green-lighter: #02a5fd; + --vp-c-green-dark: #2b4efd; + --vp-c-green-darker: #5c2bfd; +} diff --git a/docs/api/animation.md b/docs/api/animation.md new file mode 100644 index 0000000..5283743 --- /dev/null +++ b/docs/api/animation.md @@ -0,0 +1,37 @@ +# Animation API + +## spring() + +Returns a new state with a dynamically animated value of the source. + +- **Type** + + ```lua + function spring( + 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. diff --git a/docs/api/creation.md b/docs/api/creation.md new file mode 100644 index 0000000..b373726 --- /dev/null +++ b/docs/api/creation.md @@ -0,0 +1,79 @@ +# Element Creation API + +
+ +## 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 + ``` + +- ### 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 + } + + function Background(props: Layout & Children & { + Color: Color3 + }) + return create "Frame" { + BackgroundColor3 = Color, + props.Layout, + props.Children + } + end + ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md new file mode 100644 index 0000000..689864d --- /dev/null +++ b/docs/api/reactivity-core.md @@ -0,0 +1,250 @@ +# Reactivity API: Core + +
+ +## source() + +Creates a new source state with the given value. + +- **Type** + + ```lua + function source(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(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( + source: () -> Map, + transform: (value: () -> VI, index: KI) -> VO + ): Array + +- **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 + + 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( + source: () -> Map, + transform: (value: VI, index: () -> KI) -> VO + ): Array + +- **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 + + 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. + +-------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md new file mode 100644 index 0000000..df73c13 --- /dev/null +++ b/docs/api/reactivity-utility.md @@ -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) + ``` diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md new file mode 100644 index 0000000..f5ca78a --- /dev/null +++ b/docs/api/strict-mode.md @@ -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. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..203375f --- /dev/null +++ b/docs/index.md @@ -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 +--- + diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..69456e5 --- /dev/null +++ b/docs/package.json @@ -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" + } +} diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md new file mode 100644 index 0000000..83803fa --- /dev/null +++ b/docs/tut/crash-course/1-introduction.md @@ -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. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md new file mode 100644 index 0000000..18f98cb --- /dev/null +++ b/docs/tut/crash-course/2-creation.md @@ -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 diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md new file mode 100644 index 0000000..3f9b35a --- /dev/null +++ b/docs/tut/crash-course/3-components.md @@ -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. diff --git a/docs/tut/crash-course/4-state.md b/docs/tut/crash-course/4-state.md new file mode 100644 index 0000000..b8da21e --- /dev/null +++ b/docs/tut/crash-course/4-state.md @@ -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. diff --git a/docs/tut/crash-course/5-derived-state.md b/docs/tut/crash-course/5-derived-state.md new file mode 100644 index 0000000..2e765c9 --- /dev/null +++ b/docs/tut/crash-course/5-derived-state.md @@ -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" +``` diff --git a/docs/tut/crash-course/6-table-state.md b/docs/tut/crash-course/6-table-state.md new file mode 100644 index 0000000..0ad5319 --- /dev/null +++ b/docs/tut/crash-course/6-table-state.md @@ -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) + +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. diff --git a/docs/tut/crash-course/7-property-groups.md b/docs/tut/crash-course/7-property-groups.md new file mode 100644 index 0000000..cb498f6 --- /dev/null +++ b/docs/tut/crash-course/7-property-groups.md @@ -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 +} + +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" } + } +} +``` diff --git a/src/action.luau b/src/action.luau new file mode 100644 index 0000000..88f4a22 --- /dev/null +++ b/src/action.luau @@ -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 diff --git a/src/apply.luau b/src/apply.luau new file mode 100644 index 0000000..b0408ae --- /dev/null +++ b/src/apply.luau @@ -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 = graph.Node + +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(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 diff --git a/src/bind.luau b/src/bind.luau new file mode 100644 index 0000000..6f65990 --- /dev/null +++ b/src/bind.luau @@ -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 = graph.Node +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, +} diff --git a/src/cleanup.luau b/src/cleanup.luau new file mode 100644 index 0000000..4ae53d6 --- /dev/null +++ b/src/cleanup.luau @@ -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 diff --git a/src/create.luau b/src/create.luau new file mode 100644 index 0000000..baf2b0f --- /dev/null +++ b/src/create.luau @@ -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 & 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 ) diff --git a/src/defaults.luau b/src/defaults.luau new file mode 100644 index 0000000..abc3e10 --- /dev/null +++ b/src/defaults.luau @@ -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 + } +} diff --git a/src/derive.luau b/src/derive.luau new file mode 100644 index 0000000..7a05384 --- /dev/null +++ b/src/derive.luau @@ -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(fn: () -> T): () -> T + local node, node_get = create((nil :: any) :: T) + + node.cache = capture_and_link(node, fn) + + return node_get +end + +return derive diff --git a/src/flags.luau b/src/flags.luau new file mode 100644 index 0000000..962301d --- /dev/null +++ b/src/flags.luau @@ -0,0 +1 @@ +return { strict = false } diff --git a/src/graph.luau b/src/graph.luau new file mode 100644 index 0000000..eb79d8f --- /dev/null +++ b/src/graph.luau @@ -0,0 +1,151 @@ +if not game then script = (require :: any) "test/wrap-require" end + +local flags = require(script.Parent.flags) + +export type Node = { + cache: T, + derive: () -> T, + effects: { [(unknown) -> ()]: unknown }, -- weak values + children: { Node } | false -- weak values +} + +local reff = false +local refs = {} :: { Node } + +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(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(node: Node, fn: (T) -> (), key: T) + node.effects[fn :: () -> ()] = key +end + +local function run_effects(node: Node) + 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(node: Node): T + if reff then table.insert(refs, node) end + return node.cache +end + +local function set_child(parent: Node, child: Node) + 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) + 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(node: Node, 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(parent: Node, child: Node, 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(fn: (U?) -> T, arg: U?): ({ Node }, 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(child: Node, fn: () -> T): T + local nodes, value = capture(fn, nil) + + child.derive = fn + for _, parent: Node in next, nodes do + set_child(parent, child) + end + + return value :: T +end + +local function create(value: T): (Node, () -> 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 :: ((value: T) -> (Node, () -> T)) & (() -> (Node, () -> T)), +} diff --git a/src/init.luau b/src/init.luau new file mode 100644 index 0000000..1bf027b --- /dev/null +++ b/src/init.luau @@ -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 diff --git a/src/maps.luau b/src/maps.luau new file mode 100644 index 0000000..dd87536 --- /dev/null +++ b/src/maps.luau @@ -0,0 +1,145 @@ +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type Node = graph.Node +local create = graph.create +local set = graph.set +local capture = graph.capture +local link = graph.link + +type Map = { [K]: V } + +-- todo: optimize output array +local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } + local input_cache = {} :: Map + local output_cache = {} :: Map + local input_nodes = {} :: Map> + 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(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } + local cur_input_cache_up = {} :: Map + local new_input_cache_up = {} :: Map + + local output_cache = {} :: Map + local input_nodes = {} :: Map> + local output_array = {} :: { VO } + + local function recompute(data: Map) + 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 diff --git a/src/memoize.luau b/src/memoize.luau new file mode 100644 index 0000000..448f1f9 --- /dev/null +++ b/src/memoize.luau @@ -0,0 +1,17 @@ +local function memoize(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 + diff --git a/src/source.luau b/src/source.luau new file mode 100644 index 0000000..93ec8ef --- /dev/null +++ b/src/source.luau @@ -0,0 +1,25 @@ +if not game then script = require "test/wrap-require" end + +local graph = require(script.Parent.graph) +type Node = graph.Node +local create = graph.create +local set = graph.set + + +type Source = (() -> T) & ((T) -> T) + +local function source(value: T): Source + 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 :: ((value: T) -> Source) & (() -> Source) diff --git a/src/spring.luau b/src/spring.luau new file mode 100644 index 0000000..f018e5b --- /dev/null +++ b/src/spring.luau @@ -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 = graph.Node + +type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 + +type SpringData = { + 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 = (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 } = { + number = function(v1, v2, a) + return v1 + (v2 - v1)*a + end :: Lerp, + + CFrame = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Color3 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + UDim = function(v1, v2, a) + return UDim.new( + v1.Scale + (v2.Scale - v1.Scale)*a, + v1.Offset + (v2.Offset - v1.Offset)*a + ) + end :: Lerp, + + UDim2 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Vector2 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Vector3 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, +} + +local springs: { [SpringData]: Node } = {} +setmetatable(springs, { __mode = "vs" }) + +local function spring(target: () -> T, period: number?, damping_ratio: number?): () -> T + local inputs, initial_position = capture(target) + + local output, output_get = create(initial_position) + + local data: SpringData = { + 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 = 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 diff --git a/src/throw.luau b/src/throw.luau new file mode 100644 index 0000000..121d51c --- /dev/null +++ b/src/throw.luau @@ -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 diff --git a/src/watch.luau b/src/watch.luau new file mode 100644 index 0000000..a50b51a --- /dev/null +++ b/src/watch.luau @@ -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 diff --git a/test/benchmark.luau b/test/benchmark.luau new file mode 100644 index 0000000..1ea533e --- /dev/null +++ b/test/benchmark.luau @@ -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 diff --git a/test/goodsignal.luau b/test/goodsignal.luau new file mode 100644 index 0000000..6aa1fb4 --- /dev/null +++ b/test/goodsignal.luau @@ -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 diff --git a/test/mock.luau b/test/mock.luau new file mode 100644 index 0000000..e943a25 --- /dev/null +++ b/test/mock.luau @@ -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(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 +} diff --git a/test/testkit.luau b/test/testkit.luau new file mode 100644 index 0000000..d244d7f --- /dev/null +++ b/test/testkit.luau @@ -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(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 +} diff --git a/test/tests.luau b/test/tests.luau new file mode 100644 index 0000000..becea69 --- /dev/null +++ b/test/tests.luau @@ -0,0 +1,1302 @@ +local testkit = require("test/testkit") +local TEST, CASE, CHECK, FINISH, SKIP = testkit.test() + +local Signal = require "test/goodsignal" +local mock = require "test/mock" + + +local Instance, Vector3, Color3, Vector2, UDim2 = + mock.Instance, mock.Vector3, mock.Color3, mock.Vector2, mock.UDim2 + +local vide = require "src/init" + +-- force run garbage collector cycles +local function gc(n: number?) + for i = 1, n or 3 do + (collectgarbage :: any)("collect") + end +end + +local function weak(t: T & {}): T + setmetatable(t :: {}, { __mode = "kv" }) + return t +end + +TEST("graph", function() + local graph = require "src/graph" + local create = graph.create + local get = graph.get + local set = graph.set + local capture = graph.capture + local capture_and_link = graph.capture_and_link + local link = graph.link + local set_effect = graph.set_effect + + do CASE "Node creation" + local node = create(1) + CHECK(get(node) == 1) + end + + do CASE "Node value" + local node = create(0) + set(node, 1) + CHECK(get(node) == 1) + set(node, 2) + CHECK(get(node) == 2) + end + + do CASE "Capture nodes" + local node1 = create(nil) + local node2 = create(nil) + local nodes = capture(function() + return get(node1), get(node2) + end) + CHECK(nodes[1] == node1) + CHECK(nodes[2] == node2) + end + + do CASE "Linking nodes" + local parent = create(1) + local child = create(0) + + link(parent, child, function() + return get(parent) + end) + + set(parent, get(parent) + 1) + CHECK(get(child) == 2) -- child should automatically update + end + + do CASE "Capture and link nodes" + local parent = create(1) + local child = create() + + child.cache = capture_and_link(child, function() + return tostring(get(parent)) + end) + + set(parent, 2) + CHECK(get(child) == "2") + end + + --[[ + do CASE "Scoped captures" + local a = create(0) + local b = create(nil :: any) + + local count = 0 + + b.cache = capture_and_link(b, function() + count += 1 + local data = get(a) + local c = create(data) + get(c) + return c + end) + + local c = get(b) + + CHECK(count == 1) + set(c, 1) + CHECK(count == 1) + set(a, 1) + CHECK(count == 2) + end + ]] + + do CASE "Nodes garbage collection" + local wref = weak { create(1) } + gc() + CHECK(not wref[1]) + end + + do CASE "Node effect garbage collection" + do + local wref + + do + local function factory(p) -- factory function to prevent closure caching + return function() + return get(p) + end + end + + local node = create(1) + + do + local effect1 = factory(node) + local effect2 = factory(node) + + wref = weak { e1 = effect1, e2 = effect2, n = node} + + set_effect(node, effect1, {}) + set_effect(node, effect2, true) + end + + gc() + CHECK(not wref.e1) -- effect1 should gc since nothing is referencing table `t` + CHECK(wref.e2) -- effect2 should not gc as `true` is not garbage collectable + end + + gc() + CHECK(not wref.n and not wref.e2) -- node should now gc along with effect2 + end + + do + local wref + + do -- same test but for multiple nodes referenced by watcher + local function factory(a, b) + return (function(c, d) + return function() + return get(c), get(d) + end + end)(a, b) + end + + local node1 = create(1) + local node2 = create(1) + + do + local effect = factory(node1, node2) + + wref = weak { n1 = node1, n2 = node2, e = effect } + + local t1 = {} + set_effect(node1, effect, t1) + set_effect(node2, effect, t1) + end + + gc() + CHECK(not wref.e) + end + + gc() + CHECK(not wref.n1) + CHECK(not wref.n2) + end + end +end) + +TEST("source()", function() + local source = vide.source + local watch = vide.watch + + do CASE "Create source" + local state = source(1) + CHECK(state() == 1) + end + + do CASE "Set and get source value" + local state = source(1) + state(2) + CHECK(state() == 2) + end + + do CASE "Does not update if same value" + local state = source(1) + + local updates = -1 + watch(function() + state() + updates += 1 + end) + + CHECK(updates == 0) + state(1) + CHECK(updates == 0) + state(2) + CHECK(updates == 1) + end + + do CASE "Does update if same value is table" + local state = source {} + + local updates = -1 + watch(function() + state() + updates += 1 + end) + + CHECK(updates == 0) + state(state()) + CHECK(updates == 1) + end +end) + +TEST("derive()", function() + local source = vide.source + local derive = vide.derive + + do CASE "Derive new value on source change" + local inputA = source(1) + local inputB = source(2) + + local output = derive(function() + return tostring(inputA() + inputB()) + end) + + CHECK(output() == "3") + inputA(2) + CHECK(output() == "4") + end + + do CASE "Derive transformed source" + local input = source(1) + + local transform = function() + return tostring(input()) + end + + local output = derive(function() + return tonumber(transform()) + end) + + CHECK(output() == 1) + input(2) + CHECK(output() == 2) + end + + --[[ + do CASE "Cleanup" + local count, set = source(1) + + local derived = derive(function(from) + return { Value = from(count), Destroyed = false } + end, function(v) + v.Destroyed = true + end) + + local first = derived() + CHECK(first.Destroyed == false) + set(2) + local _ = derived() -- trigger recalc + CHECK(first.Destroyed == true) + end + ]] + + do CASE "Garbage collection" + do -- check that `b` does not allow gc of `a` + local wref, b + + do + local a = source(1) + + b = derive(function() + return a() + end) + + wref = weak { a } + end + + gc() + CHECK(wref[1]) + b() + end + + do -- check that `a` allows gc of `b` + local a = source(1) + + local wref + + do + local b = derive(function() + return a() + end) + + wref = weak { b } + end + + gc() + CHECK(not wref[1]) + end + end + + do CASE "Garbage collection 2" + -- creats a chain `a -> b -> c` where `a` is the source + local function setup() + local a = source(0) + + local b = derive(function() + return a() + end) + + local c = derive(function() + return b() + end) + + return weak { a, b, c }, a, b, c + end + + do -- check that `b` and `c` can gc if `a` is referenced + local wref, _a = setup() + + gc() + CHECK(not wref[2]) + CHECK(not wref[3]) + end + + do -- check that `a` and `b` wont gc if `c` is referenced + local weak, _a, _b, _c = setup() + + _a, _b = nil :: any, nil :: any + + gc() + CHECK(weak[1]) + CHECK(weak[2]) + end + + do -- check that `b` wont gc if `a` and `c` are referenced + local weak, a, _b, c = setup() + + _b = nil :: any + + gc() + CHECK(weak[2]) + + a(2) + CHECK(c() == 2) + end + end +end) + +TEST("watch()", function() + local source = vide.source + local watch = vide.watch + local cleanup = vide.cleanup + + do CASE "Capture states" + local a = source(1) + local b = source(1) + + local runcount = -1 + watch(function() + a() + b() + runcount += 1 + end) + + CHECK(runcount == 0) + a(2) + CHECK(runcount == 1) + b(2) + CHECK(runcount == 2) + end + + do CASE "Stop watch" + local a = source(1) + + local runcount = -1 + local unwatch = watch(function() + a() + runcount += 1 + end) + + unwatch() + a(2) + CHECK(runcount == 0) + end + + do CASE "Side-effect cleanup" + local state = source(1) + + local effect_runcount = 0 + local cleanup_runcount = 0 + + local unwatch = watch(function() + state() + effect_runcount += 1 + cleanup(function() cleanup_runcount += 1 end) + end) + + CHECK(effect_runcount == 1) + CHECK(cleanup_runcount == 0) + state(2) + CHECK(effect_runcount == 2) + CHECK(cleanup_runcount == 1) + + unwatch() + unwatch = nil :: any + gc() + vide.step(0) + + CHECK(effect_runcount == 2) + CHECK(cleanup_runcount == 2) + end + + do CASE "Garbage collection" + local function factory(p) + return function() + p() + end + end + + do -- state prevents gc of watcher + local state = source(1) + + local wref + + do + local effect = factory(state) + watch(effect) + wref = { effect } + end + + gc() + CHECK(wref[1]) + end + + do -- watcher can gc if stopped + local state = source(1) + + local wref, unwatch + + do + local effect = factory(state) + unwatch = watch(effect) + wref = weak { effect } + end + + gc() + CHECK(wref[1]) + + unwatch() + unwatch = nil :: any -- unwatch holds ref to effect + + gc() + CHECK(not wref[1]) + + end + + do -- state can gc with watcher + local wref + + do + local state = source(1) + local effect = factory(state) + watch(effect) + wref = weak { state } + end + + gc() + CHECK(not wref[1]) + end + end +end) + +TEST("cleanup()", function() + local source = vide.source + local watch = vide.watch + local cleanup = vide.cleanup + + do CASE "Cleanup runs for watcher" + local state = source(1) + + local watched = 0 + local cleaned = 0 + + local stop = watch(function() + state() + watched += 1 + cleanup(function() + cleaned += 1 + end) + end) + + CHECK(watched == 1) + CHECK(cleaned == 0) + + state(2) + + CHECK(watched == 2) + CHECK(cleaned == 1) + + stop() + + do -- vide detects by iterating through and checking for gc'd refs + stop = nil :: any + gc() + vide.step(0) + end + + CHECK(watched == 2) + CHECK(cleaned == 2) + end + + do CASE "Scoped" + local function setup() + local state = source(1) + local obj = { cleaned = 0 } + + local _stop = watch(function() + state() + cleanup(function() + obj.cleaned += 1 + end) + end) + + return state, obj + end + + local stateA, objA = setup() + local stateB, objB = setup() + + CHECK(objA.cleaned == 0) + CHECK(objB.cleaned == 0) + + stateA(2) + + CHECK(objA.cleaned == 1) + CHECK(objB.cleaned == 0) + + stateB(2) + + CHECK(objA.cleaned == 1) + CHECK(objB.cleaned == 1) + + do + stateA = nil :: any + stateB = nil :: any + gc() + vide.step(0) + end + + CHECK(objA.cleaned == 2) + CHECK(objB.cleaned == 2) + end + + do CASE "Multiple cleanup" + local state = source(1) + + local queue = {} + + watch(function() + state() + cleanup(function() table.insert(queue, 1) end) + cleanup(function() table.insert(queue, 2) end) + end) + + CHECK(testkit.seq(queue, {})) + state(2) + CHECK(testkit.seq(queue, { 1, 2 })) + state(3) + CHECK(testkit.seq(queue, { 1, 2, 1, 2 })) + + do + state = nil :: any + gc() + vide.step(0) + end + + -- todo: guarantee call order when gc? (currently not) + --testkit.print2(queue) + --CHECK(testkit.seq(queue, { 1, 2, 1, 2, 1, 2 })) + end +end) + +TEST("create()", function() + local create = vide.create + local source = vide.source + + do CASE "Apply default properties" + local defaults = require("src/defaults") + local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any } + CHECK(frame.BorderSizePixel == defaults.Frame.BorderSizePixel) + CHECK(frame.BorderColor3 == defaults.Frame.BorderColor3) + end + + do CASE "Set properties" + local text = create "TextLabel" { + Name = "Label", + Text = "test" + } + CHECK(text.Name == "Label") + CHECK(text.Text == "test") + end + + do CASE "Set nested properties" + local text = create "TextLabel" { + { Name = "Label" }, + Group = { Text = "test" } + } + CHECK(text.Name == "Label") + CHECK(text.Text == "test") + end + + do CASE "Independent" + local frame = create "Frame" + CHECK(frame {} ~= frame {}) + end + + do CASE "Set children" + local frame = create "Frame" { + create "TextLabel" { Name = "A" }, + create "TextLabel" { Name = "B" }, + { + create "TextLabel" { Name = "C" } :: any, + create "TextLabel" { Name = "D" }, + { + create "TextLabel" { Name = "E" } + } + }, + Children = { + create "TextLabel" { Name = "F" } :: any, + { create "TextLabel" { Name = "G" } } + } + } + + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "B") + CHECK(frame:FindFirstChild "C") + CHECK(frame:FindFirstChild "D") + CHECK(frame:FindFirstChild "E") + + CHECK(frame:FindFirstChild "F") + CHECK(frame:FindFirstChild "G") + end + + do CASE "Binding properties to state" + local name = source("Hi") + local text = source("Bye") + + local label = create "TextLabel" { + Name = name, + Text = text + } + + CHECK(label.Name == "Hi") + CHECK(label.Text == "Bye") + + name "Foo" + text "Bar" + + CHECK(label.Name == "Foo") + CHECK(label.Text == "Bar") + end + + do CASE "Binding garbage collection" + do -- instance should gc when unparented + local state = source("Hi") + + local wref = weak { + create "TextLabel" { + Text = state, + } + } + + gc() + CHECK(not wref[1]) + end + + do -- instance should not gc when parented + local state = source("Hi") + + local parent = create "Frame" {} + + local wref = weak { + create "TextLabel" { + Parent = parent, + Text = state, + } + } + + gc() + CHECK(wref[1]) + + wref[1].Parent = nil + wref[1].Parent = parent + + gc() + CHECK(wref[1]) + + wref[1]:Destroy() + + gc() + CHECK(not wref[1]) + end + + do -- instance does not allow gc of state + local label + local wref + + do + local state = source("Hi") + label = create "TextLabel" { + Name = state, + } + wref = weak { state :: any, label } + + end + + gc() + CHECK(wref[2]) + CHECK(wref[1]) + end + + do -- state and instance should gc once both exit scope + local wref + + do + local text = source("Hi") + + local box = create "TextLabel" { + Text = text, + } + + wref = weak { text = text, box = box} + end + + gc() + CHECK(not wref.text) + CHECK(not wref.box) + end + + do -- binding should gc despite state still existing after instance is gc + local state = source("Hi") + + local node = require "src/graph".capture(state)[1] + + local wref + + do + local instance = create "TextLabel" { + Text = state, + } + + wref = weak { + instance = instance, + binding = next(node.effects) + } + end + + CHECK(wref.binding) + + gc() + CHECK(not wref.instance) + CHECK(not wref.binding) + end + end + + do CASE "Bind same state to multiple instance properties" + local state = source "1" + + local text = create "TextBox" { + Name = state, + Text = state, + PlaceholderText = state + } + + state "2" + + CHECK(text.Name == "2") + CHECK(text.Text == "2") + CHECK(text.PlaceholderText == "2") + end + + do CASE "Bind children" + local state = source() + + local a, b, c = + create "TextLabel" { Name = "A" }, + create "TextLabel" { Name = "B" }, + create "TextLabel" { Name = "C" } + + local frame = create "Frame" { + state + } + + state { a, b } + + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "B") + + -- check that b is removed and c is added while a remains untouched + + state { a, c } + + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "C") + CHECK(not frame:FindFirstChild "B") + + state(nil) + + CHECK(#frame:GetChildren() == 0) + end + + --[[ + do CASE "Parent set to nil by state does not allow gc" + local frame = create "Frame" { Name = "Parent" } + local parent = source(frame :: Frame?) + + local wref = weak { + create "TextLabel" { Parent = parent, Name = "Child" } + } + + gc() + CHECK(wref[1]) + + parent(nil) + + gc() + CHECK(wref[1]) + + wref[1]:Destroy() + + gc() + CHECK(not wref[1]) + end + ]] + + do CASE "GC test" + local wref + + do + local data = setmetatable({}, {}) + local proxy = setmetatable({}, { __mode = "v" }) + + local ref = setmetatable({}, { __mode = "v" }) + + --proxy.data = data --? (this line should not affect outcome) + -- `data` strongly references `proxy` + data.connection = proxy + + -- `ref` strongly references `data` + ref[data] = proxy + + -- although `ref` is weak to values, `data` keeps `proxy` alive + -- this forms a sort of cyclic reference that the luau gc is unable to detect + + wref = { data = data, proxy = proxy } + end + + gc() + CHECK(wref.data and wref.proxy) + end +end) + +-- todo: more comprehensive tests for maps + +TEST("indexes()", function() + local source = vide.source + local indexes = vide.indexes + + do CASE "Use state" + local input = source { 1, 2, 3 } + + local output = indexes(input, function(v, k) + return tostring(v()) + end) + + CHECK("" .. input()[1] == output()[1]) + CHECK("" .. input()[2] == output()[2]) + CHECK("" .. input()[3] == output()[3]) + end + + do CASE "Cache result" + local input = source { 1, 2, 3 } + + local runcount = table.create(3, 0) + + local output = indexes(input, function(v, i) + runcount[i] += 1 + return v + end) + + input { 1, 2, 4 } + + CHECK(output()[1]() == 1) + CHECK(output()[2]() == 2) + CHECK(output()[3]() == 4) + + CHECK(runcount[1] == 1) + CHECK(runcount[2] == 1) + CHECK(runcount[3] == 1) + end + + do CASE "Removal reflected" + local input = source { 1, 2, 3 } + + local output = indexes(input, function(v, i) + return v + end) + + input { 1, 2 } + + local t = output() + + CHECK(t[1]() == 1) + CHECK(t[2]() == 2) + CHECK(t[3] == nil) + end + +--[[ + do CASE "Bind children" + local state, set = source { "A", "B", "C" } + + local derived = map(state, function(i, v) + return create "TextLabel" { + Name = v, + Text = tostring(i) + } + end) + + local frame = create "Frame" { + Name = "21", + [Children] = derived + } + + local function find(childname: string): TextLabel + return frame:FindFirstChild(childname) :: TextLabel + end + + CHECK(find "A".Text == "1") + CHECK(find "B".Text == "2") + CHECK(find "C".Text == "3") + + set { "A", "C", "D" } + + CHECK(find "A".Text == "1") + CHECK(not find "B") + CHECK(find "C".Text == "2") + CHECK(find "D".Text == "3") + end + + do CASE "Use optional destructor" + local state, set = source { 1, 2, 3 } + local derived = map(state, function(i, v) + return { Value = v, Destroyed = false } + end, function(v) + v.Destroyed = true + end) + + local first = derived() + + CHECK(first[1].Destroyed == false) + + set { 1, 2, 4 } + + local _ = derived() + + CHECK(first[1].Destroyed == false) + CHECK(first[2].Destroyed == false) + CHECK(first[3].Destroyed == true) + end + + do CASE "Garbage collection" + do -- check that `derived` does not allow gc of `state` + local state = source {} + + local derived = map(state, function(i, v) + return v + end) + + wref.state, state = state, nil :: any + wref.derived = derived + + gc() + CHECK(wref.state) + end + + do -- check that `state` allows gc of `derived` + local state = source {} + + local derived = map(state, function(i, v) + return i, v + end) :: State? + + wref.state = state + wref.derived, derived = derived, nil + + gc() + CHECK(not wref.derived) + end + end]] +end) + +TEST("values()", function() + local source = vide.source + local values = vide.values + + do CASE "Use state" + local input = source { 1, 2, 3 } + + local output = values(input, function(v, k) + return tostring(v) + end) + + CHECK("" .. input()[1] == output()[1]) + CHECK("" .. input()[2] == output()[2]) + CHECK("" .. input()[3] == output()[3]) + end + + do CASE "Cache result" + local input = source { 1, 2, 3 } + + local runcount = table.create(3, 0) + + local output = values(input, function(v, i) + runcount[v] += 1 + return i + end) + + input { 1, 3, 2 } + + CHECK(output()[1]() == 1) + CHECK(output()[2]() == 3) + CHECK(output()[3]() == 2) + + CHECK(runcount[1] == 1) + CHECK(runcount[2] == 1) + CHECK(runcount[3] == 1) + end + + do CASE "Removal reflected" + local input = source { 1, 2, 3 } + + local output = values(input, function(v, i) + return { v = v, i = i } + end) + + input { 1, 2 } + + local t = output() + + CHECK(t[1].v == 1) + CHECK(t[2].v == 2) + CHECK(t[3] == nil) + end + + do CASE "Removal reflected 2" + local input = source { 1 } + + local output = values(input, function(v, i) + return { v = v, i = i } + end) + + input { 2, 1 } + input { 1 } + + local t = output() + + CHECK(t[1].v == 1) + CHECK(t[1].i() == 1) + CHECK(t[2] == nil) + CHECK(t[3] == nil) + end +end) + +TEST("spring()", function() + local create = vide.create + local source = vide.source + local spring = vide.spring + local watch = vide.watch + + do CASE "Update state (on next hearbeat resumption cycle)" + local value = source(10) + local springed = spring(value, 1, 1) + + value(20) + CHECK(springed() == 10) + vide.step(1/60) + CHECK(springed() ~= 10) + CHECK(springed() > 10) + end + + do CASE "Garbage collection" + do -- `output` should not allow gc of `input` + local input = source(10) + local _output = spring(input) + + local wref = weak { input } + input = nil :: any + + gc() + CHECK(wref[1]) + end + + do -- `input` should allow gc of `output` + local input = source(10) + local output = spring(input) + + local wref = weak { output } + output = nil :: any + + gc() + CHECK(not wref[1]) + end + end + + + do CASE "Garbage collection (binded)" + local input = source(10) + local output = spring(input, 1, 1) + + local _label = create "TextLabel" { + Text = output + } + + local wref = { output } + output = nil :: any + + gc() + CHECK(wref[1]) -- `output` should not gc + end + + do CASE "Spring finished" + local input = source(0) + local output = spring(input) + + input(1) + vide.step(0.05) + CHECK(output() ~= input()) -- check spring is moving + vide.step(6) -- spring finished, should be internally removed from queue + CHECK(output() == input()) -- check spring is at target + + local count = -1 + watch(function() + output() + count += 1 + end) + + vide.step(1) -- attempt to cause another spring update + CHECK(count == 1) -- check no update occurs as spring is finished + -- + + gc() -- perform full gc + input(2) -- spring should be re-added to spring queue + vide.step(0) -- process spring queue + CHECK(count == 2) -- check spring was rescheduled correctly + end +end) + +TEST("Events", function() + local create = vide.create + + local function Thing(props) + local instance = Instance.new("Thing") :: any + instance.Signal = Signal.new() + + local clone = create(instance)(props) + + return clone + end + + do CASE "Connect event" + local connected = false + + local val = Thing { + Signal = function(newval) + connected = true + CHECK(newval == 1) + end + } + + -- testkit.print2(getmetatable(val)) + + CHECK(not connected) + val.Value = 1; val.Signal:Fire(val.Value) + CHECK(connected) + end +end) + +TEST("actions", function() + local create = vide.create + local action = vide.action + + do CASE "Run action" + local ran = false + + create "Frame" { + action(function(self) + ran = true + end, 1) + } + + CHECK(ran) + end + + do CASE "Priorities" + local queue = {} + + create "Frame" { + action(function(self) + table.insert(queue, 2) + end, 2), + + action(function(self) + table.insert(queue, 1) + end, 1) + } + + CHECK(testkit.seq(queue, { 1, 2 })) + end +end) + +TEST("strict", function() + vide.strict = true + + local source = vide.source + local derive = vide.derive + local watch = vide.watch + + do CASE "Error on derived callback yield" + local state = source(1) + + local ok = pcall(function() + local _derived = derive(function() + coroutine.yield() + return state() + end) + end) + + CHECK(not ok) + end + + do CASE "Error on watcher callback yield" + local state = source(1) + + local ok = pcall(function() + local _derived = watch(function() + coroutine.yield() + state() + end) + end) + + CHECK(not ok) + end + + do CASE "Run derived callback twice" + local state = source(1) + local runcount = 0 + + local _ = derive(function() + runcount += 1 + return state() + end) + + CHECK(runcount == 2) + state(2) + CHECK(runcount == 4) + end + + do CASE "Run watcher callback twice" + local state = source(1) + local runcount = 0 + + watch(function() + runcount += 1 + state() + end) + + CHECK(runcount == 2) + state(2) + CHECK(runcount == 4) + end + + -- todo: add case for strict mode bindings +end) + +local ok = FINISH() +if not ok then error("Tests failed", 0) end + +return nil diff --git a/test/wrap-require.luau b/test/wrap-require.luau new file mode 100644 index 0000000..824036c --- /dev/null +++ b/test/wrap-require.luau @@ -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 diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..34a4b15 --- /dev/null +++ b/todo.md @@ -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