commit cb002f4f27071401e4aa4d003517e2d014798331 Author: aaron <83140718+centau@users.noreply.github.com> Date: Tue Aug 8 21:33:11 2023 +0100 Initial commit 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