diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9c22f03 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: release + +on: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: build + run: rojo build default.project.json -o build.rbxm + + - name: release + run: gh release upload ${{github.event.release.tag_name}} build.rbxm + + + \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10f047c..5bb1717 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,6 @@ name: site-deploy on: - push: - branches: [main] # todo: remove later - workflow_dispatch: permissions: @@ -24,21 +21,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v4 - name: Install dependencies run: npm install - name: Build with VitePress run: npm run docs:build - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist @@ -52,4 +49,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 4a9db7f..138f29b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -8,13 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Luau zip - uses: robinraju/release-downloader@v1.6 + uses: robinraju/release-downloader@v1.9 with: repository: Roblox/luau - tag: "0.620" + tag: "0.679" fileName: luau-ubuntu.zip out-file-path: bin diff --git a/.github/workflows/wallypesde.yml b/.github/workflows/wallypesde.yml new file mode 100644 index 0000000..0c705b0 --- /dev/null +++ b/.github/workflows/wallypesde.yml @@ -0,0 +1,54 @@ +name: publish to wally and pesde + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + wally: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: login + run: wally login --token "${{ secrets.WALLY_TOKEN }}" + + - name: publish + run: wally publish + + pesde: + runs-on: ubuntu-latest + steps: + + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: setup rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: setup pesde + run: pesde install + + - name: login + run: pesde auth login --token "${{ secrets.PESDE_TOKEN }}" + + - name: publish + run: pesde publish --yes + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd0480d..18f0f88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ .vscode _local -aftman.toml sourcemap.json docs/.vitepress/dist docs/.vitepress/cache docs/node_modules docs/package-lock.json + +luau_packages/ +lune_packages/ +.pesde/ + +wally.lock +pesde.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7c39f..3e584d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -------------------------------------------------------------------------------- +## [0.4.1] - 2026-07-11 + +### Changed + +- `branch()` is now allowed to be used within a reactive scope. + +### Deprecated + +- `create()` overloads. Supported is now only `create(class)(props)`. + +### Fixed + +- `create()` types in the new solver should now work without `::`. + +-------------------------------------------------------------------------------- + +## [0.4.0] - 2026-01-17 + +### Added + +- `create("ClassName", { props })` and `create(Instance, { props })` syntax. +- `cleanup()` now accepts `thread` types. +- Implicit effects to set children can now recursively create more implicit + effects to set children. +- `spring()` returns a second value, a setter to set position, velocity and + impulse. +- Improved `spring()` updating and unscheduling. +- `show()` now receives a source to its callback returning the current value + of the condition. +- Ignore `false` passed as a child. +- Flag `vide.defaults` to disable the setting of default properties. +- Delayed scope destruction for control flow functions: `show()` `switch()` `indexes()` `values()`. +- Better `create()` types for the new type solver. + +### Changed + +- A scope can no longer be destroyed while it is active. Strict mode will check + for this. +- Implicit effects to set children now unparent all children when the effect is + destroyed. +- Error reporting should be improved with better formatting when effects invoke + other effects and no more loss of stack traces. +- Nesting parent properties now work, and they are now also checked for + duplicates like other properties. + +### Removed + +- Aggregate construction when setting properties with `create()`. + +-------------------------------------------------------------------------------- + +## [0.3.1] - 2024-10-09 + +### Added + +- Context functions now also return results. +- `version` table with current version. + +-------------------------------------------------------------------------------- + ## [0.3.0] - 2024-10-06 ### Added diff --git a/README.md b/README.md index 8ce077b..13e41b6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for a quick introduction to the library. ## Code sample -```lua +```luau local create = vide.create local source = vide.source diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7708fdf..37918e8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,70 +1,83 @@ -//import { defineConfig } from "vitepress" -import { withMermaid } from "vitepress-plugin-mermaid"; - -// https://vitepress.dev/reference/site-config -export default withMermaid({ - title: "Vide", - titleTemplate: ":title - A reactive UI library for Luau", - description: "A reactive UI library for Luau.", - base: "/vide/", - head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], - - themeConfig: { - logo: "/logo.svg", - - // 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"}, - ], - - sidebar: { - "/api/": [ - { - text: "API", - items: [ - { text: "Reactivity: Core", link: "/api/reactivity-core" }, - { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, - { text: "Reactivity: Control Flow", link: "/api/reactivity-flow" }, - { 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: "Sources", link: "/tut/crash-course/4-source" }, - { text: "Effects", link: "/tut/crash-course/5-effect" }, - { text: "Scopes", link: "/tut/crash-course/6-scope" }, - { text: "Stateful Components", link: "/tut/crash-course/7-stateful-component" }, - { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, - { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, - { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, - { text: "Control Flow", link: "/tut/crash-course/11-control-flow" }, - { text: "Actions", link: "/tut/crash-course/12-actions" }, - { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, - { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } - ] - }, - { - text: "Advanced Reactivity", - items: [ - { text: "Nested Scopes", link: "/tut/advanced/nested-scoping.md"} - ] - } - ], - }, - - socialLinks: [ - { icon: "github", link: "https://github.com/centau/vide" } - ] - } -}) +//import { defineConfig } from "vitepress" +import { withMermaid } from "vitepress-plugin-mermaid"; + +// https://vitepress.dev/reference/site-config +export default withMermaid({ + title: "Vide", + titleTemplate: ":title - A reactive UI library for Luau", + description: "A reactive UI library for Luau.", + base: "/vide/", + head: [["link", { rel: "icon", href: "/vide/logo.svg" }]], + + themeConfig: { + logo: "/logo.svg", + + search: { + provider: "local" + }, + + footer: { + message: 'Released under the MIT License.', + }, + + // 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"}, + ], + + sidebar: { + "/api/": [ + { + text: "API", + items: [ + { text: "Reactivity: Core", link: "/api/reactivity-core" }, + { text: "Reactivity: Utility", link: "/api/reactivity-utility" }, + { text: "Reactivity: Dynamic Scoping", link: "/api/reactivity-dynamic" }, + { 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: "Sources", link: "/tut/crash-course/4-source" }, + { text: "Effects", link: "/tut/crash-course/5-effect" }, + { text: "Scopes", link: "/tut/crash-course/6-scope" }, + { text: "Reactive Components", link: "/tut/crash-course/7-reactive-component" }, + { text: "Implicit Effects", link: "/tut/crash-course/8-implicit-effect" }, + { text: "Derived Sources", link: "/tut/crash-course/9-derived-source" }, + { text: "Cleanup", link: "/tut/crash-course/10-cleanup" }, + { text: "Dynamic Scopes", link: "/tut/crash-course/11-dynamic-scopes" }, + { text: "Actions", link: "/tut/crash-course/12-actions" }, + { text: "Strict Mode", link: "/tut/crash-course/13-strict-mode" }, + { text: "Concepts Summary", link: "/tut/crash-course/14-concepts" } + ] + }, + { + text: "Advanced", + items: [ + { text: "Dynamic Scopes", link: "/tut/advanced/dynamic-scopes"} + ] + }, + { + text: "Design Patterns", + items: [ + ] + } + ], + }, + + socialLinks: [ + { icon: "github", link: "https://github.com/centau/vide" } + ] + } +}) diff --git a/docs/.vitepress/theme/index.css b/docs/.vitepress/theme/index.css new file mode 100644 index 0000000..9c3ca9a --- /dev/null +++ b/docs/.vitepress/theme/index.css @@ -0,0 +1,25 @@ +.home > * > .VPNavBar:not(.top) { + backdrop-filter: blur(0.5rem); + background-color: transparent !important; +} + +.home > * > .VPNavBar > .divider { + opacity: 0; +} + +.home > .VPContent { + display: flex; + justify-content: center; + flex-direction: column; +} + +.VPHome { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.VPBadge a { + text-decoration: none; + color: inherit + } + \ No newline at end of file diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js index b8b9aa6..54a4c62 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,4 +1,8 @@ -// .vitepress/theme/index.js -import DefaultTheme from 'vitepress/theme' -import './vars.css' -export default DefaultTheme +// .vitepress/theme/index.js +import DefaultTheme from 'vitepress/theme' +import './vars.css' +import './index.css' + +export default { + extends: DefaultTheme, +} diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css index 64df54f..7fbf3b5 100644 --- a/docs/.vitepress/theme/vars.css +++ b/docs/.vitepress/theme/vars.css @@ -1,3 +1,52 @@ -:root { - --vp-c-brand-1: #3086ff; -} +/* Colors */ + +:root { + --vp-c-brand-1: #3086ff; + --vp-c-brand-2: #75aeff; + + --vp-button-brand-bg: #3661a2; + --vp-button-brand-hover-bg: #24447f; + --vp-button-brand-press-bg: #4896f3; + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + --vp-home-hero-logo-background: -webkit-linear-gradient( + 120deg, + #3661a2, + #4896f3 + ); + + --vp-home-hero-image-filter: blur(96px); + + --vp-c-bg: #f2f5f8; + --vp-c-bg-alt: #dfe8f5; + --vp-c-bg-elv: #dde7f4; + --vp-c-bg-soft: #e8f1fe; + + --vp-c-border: #c0c3c6; + --vp-c-divider: #dfe2e6; + --vp-c-gutter: #dfe2e6; + --vp-plugin-tabs-tab-bg: var(--vp-c-bg); + + --vp-badge-info-bg: #122d26; + --vp-badge-info-text: #6bdbbd; + --vp-badge-tip-bg: #132741; + --vp-badge-tip-text: #70abfa; +} + +.dark { + --vp-c-brand-2: #234782; + --vp-c-bg: #0d131b; + --vp-c-bg-alt: #111720; + --vp-c-bg-elv: #182231; + --vp-c-bg-soft: #182231; + + --vp-c-border: #111720; + --vp-c-divider: #1d273c; + --vp-c-gutter: #181d27; +} + diff --git a/docs/api/animation.md b/docs/api/animation.md index 6481cc3..ca06b39 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -1,25 +1,30 @@ -# Animation API +# Animation -## spring() +## spring() REACTIVE Returns a new source with a value always moving torwards the input source value. - **Type** - ```lua + ```luau function spring( source: () -> T & Animatable, period: number = 1, damping_ratio: number = 1 - ): () -> T + ): (() -> T, SpringControl) type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect + + type SpringControl = ({ + position: T?, + velocity: T?, + impulse: T? + }) -> () ``` - **Details** - An effect is created to update the new source every frame based on the input - source value. + Creates a reactive scope internally to detect source updates. The movement is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). @@ -39,3 +44,7 @@ Returns a new source with a value always moving torwards the input source value. You can change when the solver runs by calling `vide.step(dt)`, which will advance the simulation time by `dt` seconds and automatically stop the solver running in heartbeat. + + ::: warning + Large periods or damping ratios can break the spring. + ::: diff --git a/docs/api/creation.md b/docs/api/creation.md index 539967d..578b10f 100644 --- a/docs/api/creation.md +++ b/docs/api/creation.md @@ -1,39 +1,4 @@ -# Element Creation API - -
- -## mount() - -Runs a function in a new stable scope and optionally applies its result to a -target instance. - -- **Type** - - ```lua - function mount(component: () -> T, target: Instance?): () -> () - ``` - -- **Details** - - The result of the function is applied to a target in the same way - properties are using `create()`. - - The function is ran in a new stable scope, just like - [root()](reactivity-core.md#root). - - Returns a function that when called will destroy the stable scope. - -- **Example** - - ```lua - local function App() - return create "ScreenGui" { - create "TextLabel" { Text = "Vide" } - } - end - - mount(App, game.StarterGui) - ``` +# Element Creation ## create() @@ -41,11 +6,11 @@ Creates a new UI element, applying any given properties. - **Type** - ```lua + ```luau function create(class: string): (Properties) -> Instance function create(instance: Instance): (Properties) -> Instance - type Properties = Map + type Properties = Map ``` - **Details** @@ -76,73 +41,53 @@ Creates a new UI element, applying any given properties. Basic element creation. - ```lua - local frame = create "Frame" { - Name = "NewFrame", - Position = UDim2.fromScale(1, 0) + ```luau + local frame = create "TextButton" { + Name = "Button", + Size = UDim2.fromOffset(200, 160), + + Activated = function() + print "clicked" + end, + + create "UICorner" {} } ``` - A component using property nesting. - - ```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 = props.Color, - props.Layout, - props.Children - } - end - ``` - ## action() -Creates a callback that can be passed to `create()` to invoke custom actions on -instances. +Creates a special object that can be passed to `create()` to invoke custom +actions on instances. - **Type** - ```lua + ```luau function action((Instance) -> (), priority: number = 1): Action ``` - **Details** - When passed to `create()`, the given callback is called with the instance - being created as the only argument. Actions take precedence over property - and child assignments. + When passed to `create()`, the function is called with the instance being + created as the only argument. Actions take precedence over property and + child assignments. A priority can be optionally specified to ensure certain actions run after - other actions. Higher priority numbers are ran after lower priority numbers. + other actions. Lower priority values are ran first. - **Example** An action to listen to changed properties: - ```lua - local function changed(property: string, callback: (new) -> ()) + ```luau + local function changed(property: string, fn: (new) -> ()) return action(function(instance) - local con - instance:GetPropertyChangedSignal(property):Connect(function() - callback(instance[property]) + local cn = instance:GetPropertyChangedSignal(property):Connect(function() + fn(instance[property]) end) - -- disconnect on reactive scope destruction to allow gc of instance + -- disconnect on scope destruction to allow gc of instance cleanup(function() - con:Disconnect() + cn:Disconnect() end) end) end @@ -150,7 +95,7 @@ instances. local output = source "" create "TextBox" { - -- will update the `output` source anytime the text property is changed + -- will update the output source anytime the text property is changed changed("Text", output) } ``` @@ -161,16 +106,47 @@ A wrapper for `action()` to listen for property changes. - **Type** - ```lua - function changed(property: string, callback: (...unknown) -> ()): Action + ```luau + function changed(property: string, fn: (unknown) -> ()): Action ``` - **Details** - Will run the given callback any time the property is changed, as well as - when the action is initially run. + Will run the given function immediately and whenever the property updates. - The changed connection is disconnected when the scope the action is ran in - is destroyed. + The function is called with the updated property value. Runs with an action priority of 1. + +## mount() STABLE + +Runs a function in a new stable scope and optionally applies its result to a +target instance. + +- **Type** + + ```luau + function mount(component: () -> T, target: Instance?): () -> () + ``` + +- **Details** + + This is a utility for `root()` when parenting a component to an existing + instance. + + The result of the function is applied to a target in the same way + properties are using `create()`. + + Returns a function that when called will destroy the stable scope. + +- **Example** + + ```luau + local function App() + return create "ScreenGui" { + create "TextLabel" { Text = "Vide" } + } + end + + local destroy = mount(App, game.StarterGui) + ``` diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md index 861e0e1..ebbc5dd 100644 --- a/docs/api/reactivity-core.md +++ b/docs/api/reactivity-core.md @@ -1,36 +1,44 @@ -# Reactivity API: Core +# Reactivity: Core -
+## Scopes + +Vide code can run in one of two scopes: STABLE or REACTIVE. + +- Reactive scopes rerun if a source read within updates. +- Stable scopes never rerun. +- Reactive scopes cannot be created directly within another reactive scope. +- When a scope is destroyed, all scopes created within are also destroyed. + +Different functions in Vide's API will run code in different scopes. :::warning Yielding is not allowed in any stable or reactive scope. Strict mode will check for this. ::: -## root() +## root() STABLE -Creates and runs a function in a new stable scope. +Runs a function in a new stable scope. - **Type** - ```lua - function root(fn: (() -> ()) -> T...): (() -> (), T...) + ```luau + function root(fn: (Destructor) -> T...): (Destructor, T...) + + type Destructor = () -> () ``` - **Details** - Returns a function to destroy the root scope. Also passes this function as - the first argument into its callback. - - All values returned by the callback are also returned following the destructor. + Returns a destructor and any values returned by the callback. ## source() -Creates a new source with the given value. +Creates a new source. - **Type** - ```lua + ```luau function source(value: T): Source type Source = @@ -40,83 +48,108 @@ Creates a new source with the given value. - **Details** - Calling the returned source with no argument will return its stored value, - calling with an argument will set a new value. + Call the returned source with no argument to read its value. + Call the returned source with an argument to set its value. - **Example** - ```lua + ```luau local count = source(0) - - count() -- 0 - - count(count() + 1) -- 1 + print(count())-- 0 + count(count() + 1) + print(count()) -- 1 ``` -## effect() +## effect() REACTIVE -Runs a side-effect in a new reactive scope on source update. +Runs a function in a new reactive scope. - **Type** - ```lua - function effect(callback: () -> ()) + ```luau + function effect(fn: () -> ()) ``` - **Details** - Any time a source referenced in the callback is updated, the callback will - be reran. - - The callback is ran once immediately. + The function is ran once immediately. - **Example** - ```lua - local num = source(1) + ```luau + local count = source(1) effect(function() - print(num()) + print(count()) end) -- prints 1 - num(num() + 1) + count(2) -- prints 2 ``` -## derive() +## derive() REACTIVE -Derives a new source in a new reactive scope from existing sources. +Runs a function in a new reactive scope to compute a value for new source. - **Type** - ```lua - function derive(source: () -> T): () -> T + ```luau + function derive(fn: () -> T): () -> T ``` - **Details** - The derived source will have its value recalculated when any source source - it derives from is updated. + Anytime the reactive scope reruns, the output source value is set to what is + returned. - Anytime its value is recalculated it is also cached, subsequent calls will - retun this cached value until it recalculates again. - - The callback is ran once immediately. + The function is ran once immediately. - **Example** - ```lua + ```luau local count = source(0) local text = derive(function() return `count: {count()}` end) - text() -- "count: 0" + print(text()) -- "count: 0" count(1) - text() -- "count: 1" + print(text()) -- "count: 1" ``` --------------------------------------------------------------------------------- + A `derive()` should be used instead of a pure function when you expect it to + be read multiple times between updates, because `derive()` will cache the + result to prevent recomputing it on every read. + + ::: code-group + + ```luau [Pure Function] + local count = source(0) + + local text = function() + print "ran" + return `count: {count()}` + end + + count(1) + print(text()) -- prints "ran" followed by "count: 1" + print(text()) -- prints "ran" followed by "count: 1" + ``` + + ```luau [Derived Source] + local count = source(0) + + local text = derive(function() -- [!code highlight] + print "ran" + return `count: {count()}` + end) -- [!code highlight] + + count(1) -- prints "ran" + print(text()) -- prints "count: 1" + print(text()) -- prints "count: 1" + ``` + + ::: diff --git a/docs/api/reactivity-dynamic.md b/docs/api/reactivity-dynamic.md new file mode 100644 index 0000000..f0b3229 --- /dev/null +++ b/docs/api/reactivity-dynamic.md @@ -0,0 +1,229 @@ +# Reactivity: Dynamic Scopes + +Dynamic scopes are scopes that are created or destroyed in response to +source updates. Vide provides functions for some common use-cases for dynamic scopes. + +## show() REACTIVE + +Shows a component if the source is truthy. Optionally shows a fallback component +if the source is falsey. + +- **Type** + + ```luau + function show(source: () -> unknown, component: Constructor): () -> T? + function show(source: () -> unknown, component: Constructor, fallback: () -> U): () -> T | U + + type Constructor = () -> (T, number?) + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + The component is run in a stable scope when truthy, otherwise the stable + scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +## switch() REACTIVE + +Shows one of a set of components depending on a source and a mapping table. + +- **Type** + + ```luau + function switch(source: () -> K): (map: Map>): () -> V? + + type Constructor = () -> (T, number?) + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source updates, its value is inputted into a map to get a component + constructor. This component is then run in a stable scope. The previous + stable scope is destroyed. + + Returns a source holding an instance of the currently shown component or + `nil` if no component is currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +- **Example** + + ```luau + local logged = source(false) + + local button = switch(logged) { + [true] = function() + return Button { Text = "Log out", Toggle = logged } + end, + + [false] = function() + return Button { Text = "Log in", Toggle = logged } + end + } + ``` + +## indexes() REACTIVE + +Shows a component for each index in a table. + +- **Type** + + ```luau + function indexes( + source: () -> Map, + constructor: (value: () -> VI, index: KI) -> (VO, number?) + ): Array + ``` + +- **Details** + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each index in + the table. + + - For any added index, the `constructor` function is run in a new stable + scope to produce an instance that is cached. + - For any removed index, the stable scope for that index is destroyed. + + The `constructor` function is called with: + + 1. A *source containing the index's value*. + 2. The *index itself*. + + Anytime an existing index's value changes, the `constructor` function is not + rerun, instead, that index's corresponding source is updated with the new + value. + + Returns a source holding an array of instances currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = indexes(items, function(item, i) + return ItemDisplay { + Name = function() + return i .. ": " .. item().name + end, + + Image = function() + return "rbxassetid://" .. item().icon + end, + } + end) + ``` + +## values() REACTIVE + +Shows a component for each value in a table. + +- **Type** + + ```luau + function values( + source: () -> Map, + constructor: (value: VI, index: () -> KI) -> (VO, number?) + ): Array + +- **Details** + + Operates with the same idea as `indexes()`, but applied to values instead of + indexes. + + Creates a reactive scope internally to detect source updates. + + When the source table updates, a component is generated for each value in + the table. + + - For any added value, the `constructor` function is run in a new stable scope + to produce an instance that is cached. + - For any removed value, the stable scope for that value is destroyed. + + The `constructor` function is called with: + + 1. The *value itself*. + 2. A *source containing the value's index*. + + Anytime an existing value's index changes, the `constructor` function is not + rerun, instead, that value's corresponding source is updated with the new + index. + + Returns a source holding an array of instances currently shown. + + Destruction of the scope can be delayed by returning the number of seconds + to delay by, after the component. + + ::: warning + Having the same values appear multiple times in the input source table can + cause unexpected behavior. Strict mode has checks for this. + ::: + +- **Example** + + ```luau + type Item = { + name: string, + icon: number + } + + local items = source {} :: () -> Array + + local displays = values(items, function(item, i) + return ItemDisplay { + Name = function() + return i() .. ": " .. item.Name + end + + Image = "rbxassetid://" .. item.icon, + } + 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 values. It maps an index to a UI element. + + e.g. + - List of character or weapon stats. + + In most cases, both functions will produce the same observed result. + The main difference is performance, picking the right function to use can + result in less property updates and less re-renders. One case to note is + that `values()` works nicely when animating re-ordering of instances, since + the source index can be used to animate a change in position for the UI + element. + +-------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-flow.md b/docs/api/reactivity-flow.md deleted file mode 100644 index 6870367..0000000 --- a/docs/api/reactivity-flow.md +++ /dev/null @@ -1,218 +0,0 @@ -# Reactivity API: Control Flow - -
- -## show() - -Shows one of two components depending on an input source. - -- **Type** - - ```lua - function show(source: () -> unknown, component: () -> T): () -> T? - function show(source: () -> unknown, component: () -> T, fallback: () -> U): () -> T | U - ``` - -- **Details** - - Returns a source holding an instance of the currently shown component. - - When the input source changes from a falsey to a truthy value, the - component will be reran under a new stable scope. If it changes from a - truthy to falsey value, the stable scope the component was created in will - be destroyed, and the returned source will output `nil`, or a fallback - component if given. - - The fallback component is also ran under a new stable scope, and destroyed - when the input source switches back to truthy. - -## switch() - -Shows one of a set of components depending on an input source and a mapping table. - -- **Type** - - ```lua - function switch(source: () -> K): (map: Map V>) -> V? - ``` - -- **Details** - - Returns a source holding an instance of the currently shown component. - - When the input source changes, the new value will be used to lookup a given - mapping table to get a component, which will be ran under a new stable - scope. If the input source changes, the stable scope the component was - created in will be destroyed, and a new component created under a new - stable scope. If no component is found for an input value, the switch will - output `nil`. - -- **Example** - - ```lua - local logged = source(false) - - local button = switch(logged) { - [true] = function() - return Button { Text = "Log out", Toggle = logged } - end, - - [false] = function() - return Button { Text = "Log in", Toggle = logged } - end - } - ``` - -## indexes() - -Maps each index in a table source to an object. - -- **Type** - - ```lua - function indexes( - source: () -> Map, - transform: (value: () -> VI, index: KI) -> VO - ): Array - -- **Details** - - Returns a source holding an array of instances currently shown. - - When the input source changes, each *index* in the new table is compared with - the last input table. - - - For any new index, the `transform` function is ran under a new stable - scope to produce a new instance. - - For any removed index, the stable scope for that index is destroyed. - - Unchanged indexes are untouched. - - The transform function is called only ever *once* for each index in the - source table. - - 1. First argument is a *source containing the index's value*. - 2. Second argument is the *index itself*. - - Anytime an existing index's value changes, the transform function is not - rerun, instead the source value for that index will update, causing anything - depending on it to update too. - -- **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(items, function(item, i) - return ItemDisplay { - Name = function() - return i .. ": " .. item().name - end, - - Image = function() - return "rbxassetid://" .. item().icon - end, - } - end) - ``` - -## values() - -Maps each value in a table source to an object. - -- **Type** - - ```lua - function values( - source: () -> Map, - transform: (value: VI, index: () -> KI) -> VO - ): Array - -- **Details** - - Returns a source holding an array of instances currently shown. - - When the input source changes, each *value* in the new table is compared with - the last input table. Similar to `indexes()` but for values instead of indexes. - - - For any new value, the `transform` function is ran under a new stable - scope to produce a new instance. - - For any removed value, the stable scope for that value is destroyed. - - Unchanged values are untouched. - - The transform function is only ever called *once* for each value in the - source table. - - 1. First argument is the *value itself*. - 2. Second argument is a *source containing the value's index*. - - Anytime an existing value's index changes, the transform function is not - rerun, instead the source index for that value will update, causing anything - depending on it to update too. - - ::: warning - Having primitive values in the input source table can cause unexpected - behavior, as duplicate values can result in multiple tranforms being ran for - a single value, meaning there can be multiple source indexes bound to the - same UI element. Strict mode has checks for this. - ::: - -- **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(items, function(item, i) - return ItemDisplay { - Name = function() - return i() .. ": " .. item.Name - end - - Image = "rbxassetid://" .. item.icon, - } - 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 values. 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. One case to note is - that `values()` works nicely when animating re-ordering of instances, since - the value is not destroyed when indexes are changed, and the source index - can be used to animate a change in position for the UI element. - --------------------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md index 824fe35..b8bff4d 100644 --- a/docs/api/reactivity-utility.md +++ b/docs/api/reactivity-utility.md @@ -1,41 +1,47 @@ -# Reactivity API: Utility +# Reactivity: Utility ## cleanup() -Runs a callback anytime a scope is reran or destroyed. +Queues a callback to run when a scope is reran or destroyed. - **Type** - ```lua - function cleanup(callback: () -> ()) - function cleanup(obj: Destroyable) - function cleanup(obj: Disconnectable) + ```luau + function cleanup(v: Function | Disconnectable | Destroyable | thread) + type Function = () -> () type Destroyable = { destroy: () -> () } type Disconnectable = { disconnect: () -> () } ``` - **Example** - ```lua - local data = source(1) + ```luau + local count = source(0) - effect(function() - local label = create "TextLabel" { Text = data() } + local destroy = root(function() + effect(function() + count() - cleanup(function() - label:Destroy() + cleanup(function() + print "cleaned" + end) end) - end) + end + + -- nothing printed yet + count(1) -- prints "cleaned" + count(2) -- prints "cleaned" + destroy() -- prints "cleaned" ``` -## untrack() +## untrack() STABLE -Runs a given function in a new stable scope. +Runs a function in a new stable scope. - **Type** - ```lua + ```luau function untrack(source: () -> T): T ``` @@ -46,7 +52,7 @@ Runs a given function in a new stable scope. - **Example** - ```lua + ```luau local a = source(0) local b = source(0) @@ -55,65 +61,84 @@ Runs a given function in a new stable scope. end) print(sum()) -- 0 - b(1) + b(1) -- untracked so reactive scope created by derive() does not rerun print(sum()) -- 0 - a(1) + a(1) -- reactive scope created by derive() reruns print(sum()) -- 2 ``` ## read() -Utility used to read a value that is either a primitive or a source. Sources -read can still be tracked inside a reactive scope. +Utility used to read a value that is either a primitive or a source. - **Type** - ```lua + ```luau function read(value: T | () -> T): T ``` ## batch() -Runs a given function where any source updates made within the function do not -trigger effects until after the function finishes running. +Runs a function where any source updates made within the function do not +trigger effects until after the function ends. - **Type** - ```lua + ```luau function batch(fn: () -> ()) ``` - **Details** Improves performance when an effect depends on multiple sources, and those - sources need to be updated. Updating those sources inside a batch call will - only cause the effect to run once after the batch call ends instead of after - each time a source is updated. + sources need to be updated. -## context() +- **Example** + + ```luau + local a = source(0) + local b = source(0) + + effect(function() + print(a() + b()) + end) + + -- prints "0" + + batch(function() + a(1) -- no print + b(2) -- no print + end) + + -- prints "3" + ``` + +## context() STABLE Creates a new context. - **Type** - ```lua + ```luau function context(default: T): Context type Context = () -> T -- get - & (T, () -> ()) -> () -- set + & (T, () -> U) -> U -- set ``` - **Details** Calling `context()` returns a new context function. Call this function with no arguments to get the context value. - Call this function with a value and a callback to set a new context with the - given value. + Call this function with a value and a function to create a new context with + the given value. + + The new context is run under a stable scope. - **Example** - ```lua + ```luau local theme = context() local function Button() @@ -131,4 +156,3 @@ Creates a new context. end) ``` --------------------------------------------------------------------------------- diff --git a/docs/api/strict-mode.md b/docs/api/strict-mode.md index 570108b..6f9ee80 100644 --- a/docs/api/strict-mode.md +++ b/docs/api/strict-mode.md @@ -2,7 +2,7 @@ Strict mode is library-wide and can get set by doing: -```lua +```luau vide.strict = true ``` @@ -14,25 +14,24 @@ and identifying improper usage. Currently, strict mode will: -1. Run derived sources twice a source updates. -2. Run effects twice when a source updates. -3. Throw an error if yields occur where they are not allowed. -4. Checks for `indexes()` and `values()` returning primitive values. -5. Checks for `values()` input having duplicate values. -6. Checks for duplicate nested properties at same depth. -7. Better error reporting and stack traces + creation traces of property bindings. +1. Run reactive scopes twice when a source updates. +2. Throw an error if yields occur where they are not allowed. +3. Checks for `indexes()` and `values()` outputting primitive values. +4. Checks for `values()` input having duplicate values. +5. Checks for duplicate nested properties at same depth. +6. Checks for destruction of an active scope. +7. Better error reporting and stack traces. -By rerunning derived sources and effects twice each time they update, it helps -ensure that derived source computations are pure, and that any -cleanups made in derived sources or effects are done correctly. +By rerunning reactive scopes twice each time they update, it helps ensure that +computations are pure, and that any cleanup is done correctly. Accidental yielding within reactive scopes can break Vide's reactive graph, which strict mode will catch. As well as additional safety checks, Vide will dedicate extra resources to recording and better emitting stack traces where errors occur, particularly -when binding properties to sources. +when implicit effects are created for instance property updating. It is recommended to develop UI with strict mode and to disable it when pushing to -production. In Roblox, production code compiles at O2 by default, so you don't +production. In Roblox, production code compiles at O2 by default, so you do not need to worry about disabling strict mode unless you have manually enabled it. diff --git a/docs/index.md b/docs/index.md index 6fe3a4a..295d137 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,14 +1,16 @@ --- # https://vitepress.dev/reference/default-theme-home-page layout: home +pageClass: home +next: + text: 'Introduction' + link: '/tut/crash-course/1-introduction' hero: - name: Vide - text: "" + name: "Vide" tagline: A reactive UI library for Luau. image: src: /logo.svg - alt: Vide actions: - theme: brand text: Tutorials @@ -17,8 +19,4 @@ hero: text: API Reference link: /api/reactivity-core -features: - - title: In Development - details: Not recommended for production use. --- - diff --git a/docs/package.json b/docs/package.json index 5dce993..a329007 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,14 +1,12 @@ -{ - "type": "module", - - "scripts": { - "docs:dev": "vitepress dev", - "docs:build": "vitepress build", - "docs:preview": "vitepress preview" - }, - - "devDependencies": { - "vitepress": "1.0.0-rc.25", - "vitepress-plugin-mermaid": "2.0.14" - } -} +{ + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.4.5", + "vitepress-plugin-mermaid": "2.0.17" + } +} diff --git a/docs/public/full_logo.svg b/docs/public/full_logo.svg index 85488e0..1962800 100644 --- a/docs/public/full_logo.svg +++ b/docs/public/full_logo.svg @@ -1,4 +1,4 @@ - + diff --git a/docs/public/logo.svg b/docs/public/logo.svg index 92b06c1..22d840a 100644 --- a/docs/public/logo.svg +++ b/docs/public/logo.svg @@ -1,37 +1,31 @@ - - - - - + + + + + - + + - - - + + + - + - - - + + + - - - - - - - - + - + - + diff --git a/docs/tut/advanced/dynamic-scopes.md b/docs/tut/advanced/dynamic-scopes.md new file mode 100644 index 0000000..139a19c --- /dev/null +++ b/docs/tut/advanced/dynamic-scopes.md @@ -0,0 +1,148 @@ +# Dynamic Scopes + +Dynamic scopes are scopes that are created and destroyed in response to +source updates. This is needed for conditionally rendering parts of your UI, +such as opening and closing menus. + +While Vide provides functions for common ways to do this, this section will +show how you can implement them yourself so you are not limited by only what is +provided. + +## Recreating [`show()`](/api/reactivity-dynamic#show-reactive) + +The most basic one, `show()`, can be +implemented yourself like so: + +```luau +local function show(toggle: () -> unknown, component: () -> Instance) + return derive(function() + return if toggle() then untrack(component) else nil + end) +end +``` + +The main thing to note here is the use of `untrack()`. This function runs its +callback in a new stable scope. Without this, if the component were to create +a reactive scope, an error would occur since a reactive scope cannot be created +within a reactive scope. + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#444455", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#444455" + } +}}%% + +graph + +subgraph derive ["derive (reactive)"] + + subgraph untrack ["untrack (stable)"] + subgraph effect ["effect (reactive)"] + + end + end +end +``` + +You can see from the above graph how the effect would not be created directly +inside the derive, there is a stable scope between them. This requirement exists +as a guard against unintentional rerendering of UI. + +## Recreating [`switch()`](/api/reactivity-dynamic#switch-reactive) + +```luau +local function switch(key) + return function(map) + return derive(function() + local component = map[key()] + return if component then untrack(component) else nil + end) + end +end +``` + +## Recreating [`indexes()`](/api/reactivity-dynamic#indexes-reactive) + +This is a more complicated function because it manages multiple scopes at the +same time, unlike the previous functions. Because some scopes may persist +between reruns, we cannot use `untrack()` anymore which automatically destroys +on rerun; we must use `root()` where the lifetime of each scope is managed +manually and independently. + +```luau +local function indexes( + input: () -> Map, + transform: (value: () -> VI, index: I) -> VO +) + local index_caches = {} :: Map VI, + destroy: () -> () + }?> + + -- destroy all scopes if the parent scope is destroyed + cleanup(function() + for _, cache in index_caches do + assert(cache).destroy() + end + end) + + return derive(function() + local new_input = input() + + -- destroy scopes of removed indexes + for i, cache in index_caches do + if new_input[i] == nil then + assert(cache).destroy() + index_caches[i] = nil + end + end + + -- create scopes or update sources of added or changed index values + for i, v in new_input do + local cache = index_caches[i] + + if cache == nil then -- no scope created for this index, create one + local src = source(v) + + local destroy, result = root(function() + return transform(src, i) + end) + + index_caches[i] = { + destroy = destroy, + source = src, + output = result, + previous_input = v + } + elseif cache.previous_input ~= v then -- scope exists, update source + cache.previous_input = v + cache.source(v) + else -- scope exists and value has not changed; do nothing + end + end + + -- return the cached output values as an array + local array = table.create(#index_caches) + + for _, cache in index_caches do + table.insert(array, assert(cache).output) + end + + return array + end) +end +``` + +-------------------------------------------------------------------------------- + +Though the above functions are already provided to you by Vide, this serves as +an example for how you may create your own dynamic scope functions. diff --git a/docs/tut/advanced/nested-scoping.md b/docs/tut/advanced/nested-scoping.md deleted file mode 100644 index c5e6b4b..0000000 --- a/docs/tut/advanced/nested-scoping.md +++ /dev/null @@ -1,192 +0,0 @@ -# Nested Scopes - -Nesting scopes gives you finer control over the reactive graph, but needs more work to do. The built-in control flow functions try to cover the -most common cases, but they do not cover all of them. - -This tutorial will demonstrate how to implement a `show()` control flow function -using just sources and effects. - -```lua -local mount = vide.mount -local source = vide.source -local show = vide.show - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -root(function() - local toggled = source(true) - - show(toggled, Button) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> show - - subgraph show[show effect] - text[Text effect] - end -end -``` - -Above is the reactive graph for `show()`. It creates a new effect depending on -`toggle` where anytime `toggle` is truthy, it will create a new `Counter`. The -`show` effect calls `Counter`, which creates a new reactive scope to update its -text whenever `count` changes. As per the rules of reactive scopes, a reactive -scope rerunning will destroy any scopes created within it. So the text -effect's reactive scope is destroyed whenever the show effect is rerun. - -The same can be achieved without the use of `show()`: - -```lua -local mount = vide.mount -local source = vide.source -local effect = vide.effect -local cleanup = vide.cleanup - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -mount(function() - local toggled = source(true) - - effect(function() - if toggled() then - local destroy = root(function() - Counter() - end) - cleanup(destroy) - end - end) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> effect - - subgraph effect - subgraph mount2[inner mount] - text[Text effect] - end - end -end -``` - -This is another way to achieve the same. Here we use `root()` within the effect -to manually create and destroy a new stable scope whenever the effect reruns. - -The reason for creating a stable scope is to prevent the effect from tracking -any sources that may be read inside the `Counter()` call. Otherwise, the effect -may be rerun needlessly and recreate the counter. - -Alternatively, instead of using `root()`: - -```lua -local mount = vide.mount -local source = vide.source -local effect = vide.effect -local untrack = vide.untrack - -local function Counter() - local count = source(0) - - return create "TextButton" { - Text = count, - Activated = function() count(count() + 1) end - } -end - -mount(function() - local toggled = source(true) - - effect(function() - if toggled() then - untrack(Button) - end - end) -end) -``` - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph mount - direction LR - toggle --> effect - - subgraph effect - text[Text effect] - end -end -``` - -Without the use of `untrack()`, an error would occur, since Vide does not allow -the creation of reactive scopes inside reactive scopes. `untrack()` creates a -stable scope inside the reactive scope, and we can create another reactive scope -inside that stable scope. The -reason for this, is because if the `Counter` component reads from a source -internally, that can cause the reactive scope calling `Counter()` to track that -source, causing unintentional reruns. As a guard against this, you are forced to -use `untrack()` to create nested reactive scopes. - -The final result is the same as using the `show()` component. An effect is -created which creates the counter, which creates its own reactive scope. The -effect rerunning causes the counter's internal reactive scope to be destroyed, -making sure everything is cleaned up. diff --git a/docs/tut/control-flow/1-intro.md b/docs/tut/control-flow/1-intro.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/tut/control-flow/2-show.md b/docs/tut/control-flow/2-show.md deleted file mode 100644 index 8628e45..0000000 --- a/docs/tut/control-flow/2-show.md +++ /dev/null @@ -1 +0,0 @@ -# show() diff --git a/docs/tut/control-flow/3-switch.md b/docs/tut/control-flow/3-switch.md deleted file mode 100644 index 647835a..0000000 --- a/docs/tut/control-flow/3-switch.md +++ /dev/null @@ -1 +0,0 @@ -# switch() diff --git a/docs/tut/control-flow/4-indexes.md b/docs/tut/control-flow/4-indexes.md deleted file mode 100644 index aad2c90..0000000 --- a/docs/tut/control-flow/4-indexes.md +++ /dev/null @@ -1 +0,0 @@ -# indexes() diff --git a/docs/tut/control-flow/5-values.md b/docs/tut/control-flow/5-values.md deleted file mode 100644 index 8909904..0000000 --- a/docs/tut/control-flow/5-values.md +++ /dev/null @@ -1 +0,0 @@ -# values() diff --git a/docs/tut/crash-course/1-introduction.md b/docs/tut/crash-course/1-introduction.md index 983f51b..564aa2d 100644 --- a/docs/tut/crash-course/1-introduction.md +++ b/docs/tut/crash-course/1-introduction.md @@ -6,10 +6,16 @@ Vide is heavily inspired by [Solid](https://www.solidjs.com/). ## Why Vide? -Vide provides a reactive and declarative API to simplify managing UI. +Vide's reactive and declarative API aims to let you program UI as simply as +possible, with a strong focus on how data flows through your application. -Some of the main focuses behind Vide's design choices: +Some of Vide's main design choices: -- Minimal syntax -- Complete typechecking -- Independence from instances +- Syntax minimal. +- Data oriented. +- Typechecking compatible. +- Instance independent. + +Vide's reactivity operates with the concept +of scopes which carries a learning curve, though is what makes Vide's minimal +syntax possible. The crash course will introduce these concepts gradually. diff --git a/docs/tut/crash-course/10-cleanup.md b/docs/tut/crash-course/10-cleanup.md index f6ca5f3..e25d451 100644 --- a/docs/tut/crash-course/10-cleanup.md +++ b/docs/tut/crash-course/10-cleanup.md @@ -5,14 +5,14 @@ a side-effect from a source update. Vide provides a function `cleanup()` which is used to queue a callback for the next time a reactive scope is rerun or destroyed, or when a stable scope is destroyed. -```lua +```luau local root = vide.root local source = vide.source local effect = vide.effect +local cleanup = vide.cleanup local count = source(0) - local destroy = root(function() effect(function() local x = count() diff --git a/docs/tut/crash-course/11-control-flow.md b/docs/tut/crash-course/11-control-flow.md deleted file mode 100644 index b206915..0000000 --- a/docs/tut/crash-course/11-control-flow.md +++ /dev/null @@ -1,98 +0,0 @@ -# Control Flow - -Eventually you may need a way to dynamically create and destroy UI elements -resulting from source updates. Vide provides functions to help you do this, -known as *control flow* functions. - -These functions return new sources, which hold the instances to be displayed. -The new sources can be used in `create()` to update the children of a container -instance. - -## indexes() - -`indexes()` *maps* each table index to a new UI element that can -update to display the current value at that index. Each table index is given a -single corresponding UI element. - -```lua -local list = source { - "finish the crash course", - "star Vide's GitHub" -} - -local function TodoList(props: { list: () -> Array }) - return create "Frame" { - create "UIListLayout" {}, - - indexes(list, function(todo, i) - return create "TextLabel" { - Text = function() - return i .. ": " .. todo() - end, - - LayoutOrder = i - } - end) - } -end - -TodoList { list = list } -``` - -For each index in the given source table, the given function to `indexes()` will -be run in a new stable scope with: - -1. a source containing the value at the index -2. the index itself - -When the value at an index is changed, the function is not reran. Instead, the -given source for that index is updated. - -Any time the input source table is updated, the given function will be ran for -any newly added indexes, while any removed indexes (indexes now with a `nil` -value), will have its corresponding stable scope destroyed. - - - -The reactive graph for the above example: - -```mermaid -%%{init: { - "theme": "base", - "themeVariables": { - "primaryColor": "#1B1B1F", - "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", - "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" - } -}}%% - -graph - -subgraph root ["root scope"] - direction LR - todoList --> indexes -.- subroot1 & subroot2 - - subgraph subroot1 ["indexes scope 1"] - direction LR - value1[todo] --> prop1["prop binding"] - end - - subgraph subroot2 ["indexes scope 2"] - direction LR - value2[todo] --> prop2[prop binding] - end -end -``` - -When you edit a table in a source, you must set that table again to actually -update the source. - -```lua -local src = source { 1, 2 } -local data = src() -table.insert(data, 3) -- no effects will run -src(data) -- effects will run -``` diff --git a/docs/tut/crash-course/11-dynamic-scopes.md b/docs/tut/crash-course/11-dynamic-scopes.md new file mode 100644 index 0000000..1c12e51 --- /dev/null +++ b/docs/tut/crash-course/11-dynamic-scopes.md @@ -0,0 +1,211 @@ +# Dynamic Scopes + +Eventually you may need a way to dynamically create and destroy UI elements +resulting from source updates. Vide provides functions to help you do this, +known as *dynamic scope* functions. + +These functions create and destroy scopes for you in response to source +updates. They return a source containing the created component. This source can +be parented as a child which will update the shown children whenever the source +updates. + +The simplest example is using `show()`. + +```luau +local source = vide.source +local create = vide.create +local show = vide.show +local root = vide.root + +function Button(props: { Text: string, Activated: () -> () }) + return create "TextButton" { + Text = props.Text, + Activated = props.Activated + } +end + +function Menu() + return create "TextLabel" { + Text = "This is a menu" + } +end + +function App() + local toggled = source(false) + + return create "ScreenGui" { + Button { + Text = "Toggle Menu", + Activated = function() + toggled(not toggled()) + end + }, + + show(toggled, Menu) -- [!code highlight] + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +This is a complete example of rendering UI which has a single button that +toggles the opening of a menu. + +-------------------------------------------------------------------------------- + +Another common function is `indexes()`. This function creates a component for +each index in a table. + +Each component created is done so in a new and independent stable scope. The +indexes of the table are checked each source update to prevent redunant +destruction and recreation of UI elements. + +```luau +local source = vide.source +local create = vide.create +local indexes = vide.indexes +local root = vide.root + +local function Todo(props: { + Text: () -> string, + Position: number, + Activated: () -> () +}) + return create "TextButton" { + Text = function() return props.Position .. ": " .. props.Text() end, + LayoutOrder = props.Position, + Activated = Activated + } +end + +local function TodoList(props: { List: () -> Array }) + return create "Frame" { + create "UIListLayout" {}, + + indexes(props.List, function(text, i) -- [!code highlight] + return Todo { + Text = text, + Position = i, + Activated = function() -- remove the todo when clicked + local list = props.List() + table.remove(list, i) + props.List(list) + end + } + end) + } +end + +function App() + local list = source { + "finish the crash course", + "star Vide's GitHub" + } + + return create "ScreenGui" { + TodoList { List = list }, + } +end + +root(function() + App().Parent = game.StarterGui +end) +``` + +The reactive graph for the above example: + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "primaryColor": "#111720", + "primaryTextColor": "#fff", + "primaryBorderColor": "#111720", + "lineColor": "#79B8FF", + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" + } +}}%% + +graph + +subgraph root ["root"] + direction LR + todoList --> indexes -.- subroot1 & subroot2 + + subgraph subroot1 ["indexes scope 1"] + direction LR + value1[todo] --> prop1["prop binding"] + end + + subgraph subroot2 ["indexes scope 2"] + direction LR + value2[todo] --> prop2[prop binding] + end +end +``` + +When you edit a table in a source, you must set that table again to actually +update the source. + +```luau +local src = source { 1, 2 } +local data = src() +table.insert(data, 3) -- no effects will run +src(data) -- effects will run +``` + +-------------------------------------------------------------------------------- + +All dynamic scope functions also support delaying the destruction of the scope. +This is useful for playing any sort of animation or effect before the UI +instance is removed. + +If you have the following code, for example: + +```lua +local function Menu() + return create "Frame" {} +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function() + return Menu {} + end) +} + +toggled(false) -- menu will disappear immediately +``` + +```lua +local function Menu(props: { Visible: () -> boolean }) + local transparency = spring(function() + return if p.Visible then 0 else 1 + end + + return create "Frame" { + BackgroundTransparency = transparency + } +end + +local toggled = source(true) + +create "ScreenGui" { + show(toggled, function(_, present) + return Menu { p.Visible = present }, 3 -- give a generous 3 seconds for the spring to complete before destroying + end) +} + +toggled(false) +-- `present` will go `false` immediately +-- transparency will begin being sprung +-- after 3 seconds the scope is destroyed, giving the spring enough time to complete +``` + +If `toggled` goes from truthy to falsey, beginning the timer, but then back +to truthy before the timer finishes, the timer is cancelled and the scope is +not destroyed. diff --git a/docs/tut/crash-course/12-actions.md b/docs/tut/crash-course/12-actions.md index 176c12e..60e5c30 100644 --- a/docs/tut/crash-course/12-actions.md +++ b/docs/tut/crash-course/12-actions.md @@ -1,13 +1,11 @@ # Actions -Actions in Vide are special callbacks that you can pass along with properties, +Actions are special callbacks that you can pass along with properties, to run some code on an instance receiving them. -```lua +```luau local action = vide.action -``` -```lua create "TextLabel" { Text = "test", @@ -22,14 +20,15 @@ create "TextLabel" { Actions can be wrapped with functions for reuse. Below is an example of an action used to listen for property changes: -```lua +```luau local action = vide.action +local source = vide.source local effect = vide.effect local cleanup = vide.cleanup -local function changed(prop: string, callback: (new) -> ()) +local function changed(property: string, callback: (new) -> ()) return action(function(instance) - local connection = instance:GetPropertyChangedSignal(prop):Connect(function() + local connection = instance:GetPropertyChangedSignal(property):Connect(function() callback(instance[property]) end) @@ -49,7 +48,7 @@ effect(function() print(output()) end) -instance.Text = "foo" -- "foo" will be printed from the effect +instance.Text = "foo" -- "foo" will be printed by the effect ``` The source `output` will be updated with the new property value any time it is diff --git a/docs/tut/crash-course/13-strict-mode.md b/docs/tut/crash-course/13-strict-mode.md index 6efa1c3..0de8887 100644 --- a/docs/tut/crash-course/13-strict-mode.md +++ b/docs/tut/crash-course/13-strict-mode.md @@ -13,7 +13,7 @@ Strict mode will run derived sources and effects twice each time they update. This is to help ensure that derived source computations are pure, and that any cleanups made in derived sources or effects are done properly. -```lua +```luau local source = vide.source local effect = vide.effect @@ -23,6 +23,7 @@ local count = source(0) local ran = 0 effect(function() + count() ran += 1 end) diff --git a/docs/tut/crash-course/14-concepts.md b/docs/tut/crash-course/14-concepts.md index 24ca703..f33d043 100644 --- a/docs/tut/crash-course/14-concepts.md +++ b/docs/tut/crash-course/14-concepts.md @@ -30,7 +30,7 @@ Created by: - `root()` - `untrack()` -- `switch()` +- `show()` - `indexes()` Stable scopes do not track sources and never rerun. @@ -47,23 +47,14 @@ Created by: Reactive scopes do track sources and will rerun when those sources update. Reactive scopes cannot be created within a reactive scope, but stable scopes -can. +can be created within a reactive scope. -## Scope Owners +## Scope Cleanup -A scope created within another scope is *owned* by the other scope, with the -exception of the scope created by `root()`. +When a scope is rerun or destroyed, all scopes created within it are +automatically destroyed. -When a scope is rerun or destroyed, all scopes owned by it are automatically -destroyed. - -`root()` creates a stable scope with no owner, instead it is destroyed manually. - -## Cleanup - -Arbitrary code to run whenever a stable or reactive scope is rerun or destroyed. - -Queue a function to run using `cleanup()`. +Any functions queued by `cleanup()` are also ran. ## Reactive Graph @@ -73,12 +64,12 @@ relationships between effects and the sources they depend on. ### Code -```lua +```luau local count = source(0) root(function() local text = derive(function() - return "count: " .. text() + return "count: " .. count() end) effect(function() @@ -93,12 +84,12 @@ end) %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#1C1C1F" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#202530" } }}%% @@ -118,6 +109,5 @@ Notes: - An update to `count` will cause `text` to rerun, which then causes `effect` to rerun. - When the root scope is destroyed, `text` and - `effect` will be destroyed alongside it, since they are - owned by it. `count` will be untouched and future updates - to `count` will have no effect. + `effect` will be destroyed alongside it, since they were created within it. + `count` will be untouched and future updates to `count` will have no effect. diff --git a/docs/tut/crash-course/2-creation.md b/docs/tut/crash-course/2-creation.md index 5e76173..668a470 100644 --- a/docs/tut/crash-course/2-creation.md +++ b/docs/tut/crash-course/2-creation.md @@ -5,7 +5,7 @@ Instances are created using `create()`. Parentheses `()` can be omitted when calling functions with string or table literals for brevity. -```lua +```luau local create = vide.create return create "ScreenGui" { diff --git a/docs/tut/crash-course/3-components.md b/docs/tut/crash-course/3-components.md index 8e5890f..0b803ca 100644 --- a/docs/tut/crash-course/3-components.md +++ b/docs/tut/crash-course/3-components.md @@ -10,7 +10,7 @@ together. ::: code-group -```lua [Button.luau] +```luau [Button.luau] local create = vide.create local function Button(props: { @@ -34,7 +34,7 @@ end return Button ``` -```lua [Menu.luau] +```luau [Menu.luau] local create = vide.create local Button = require(Button) diff --git a/docs/tut/crash-course/4-source.md b/docs/tut/crash-course/4-source.md index 08da45c..d862156 100644 --- a/docs/tut/crash-course/4-source.md +++ b/docs/tut/crash-course/4-source.md @@ -5,7 +5,7 @@ Vide's reactivity. A source can be created using `source()`. -```lua +```luau local source = vide.source local count = source(0) @@ -16,13 +16,13 @@ The value passed to `source()` is the initial value of the source. The value of a source can be set by calling it with an argument, and can be read by calling it with no arguments. -```lua +```luau count(count() + 1) -- increment count by 1 ``` Sources can be *derived* by wrapping them in functions. -```lua +```luau local count = source(0) local text = function() diff --git a/docs/tut/crash-course/5-effect.md b/docs/tut/crash-course/5-effect.md index 5e6043c..82768dc 100644 --- a/docs/tut/crash-course/5-effect.md +++ b/docs/tut/crash-course/5-effect.md @@ -1,11 +1,11 @@ # Effects -Effects are functions that are ran in response to source updates. They are +Effects are functions that are ran in response to source updates. A source and effect is analogous to a signal and connection. Effects are created using `effect()`. -```lua +```luau local source = vide.source local effect = vide.effect @@ -23,10 +23,13 @@ count(1) Any source read inside an effect is tracked and will rerun the effect when that source is updated. -Derived sources are also tracked, it doesn't matter how deeply nested +The effect runs its callback once immediately to initially figure out what +sources are being read. + +Derived sources are also tracked, it does not matter how deeply nested inside a function a source is. -```lua +```luau local source = vide.source local effect = vide.effect @@ -47,3 +50,22 @@ count(2) If a source is updated with the same value it already had, it will not rerun effects depending on it. + +You can also read from a source within an effect without the effect tracking it. + +```luau +local source = vide.source +local effect = vide.effect +local untrack = vide.untrack + +local a = source(0) +local b = source(0) + +effect(function() + print(`a: {a()} b: {untrack(b)}`) +end) + +a(1) -- prints "a: 1 b: 0" +b(1) -- prints nothing +a(2) -- prints "a: 2 b: 1" +``` diff --git a/docs/tut/crash-course/6-scope.md b/docs/tut/crash-course/6-scope.md index 2853fff..e2cea69 100644 --- a/docs/tut/crash-course/6-scope.md +++ b/docs/tut/crash-course/6-scope.md @@ -7,62 +7,46 @@ But the disconnecting of many signals and connections is tedious and verbose. Vide instead operates on the concept of scopes which provides a much cleaner API, given that you follow a few rules. -Scopes come in two flavors; stable and reactive. +There are two types of scopes: stable and reactive. -- All scopes must be created within another scope with the exception of `root()` -- Stable scopes never rerun -- Reactive scopes can rerun -- A reactive scope cannot be created within another reactive scope +- A scope must be created within another scope. +- Stable scopes never rerun. +- Reactive scopes can rerun. +- A reactive scope cannot be created within another reactive scope, only within + a stable scope. + +An exception to the first rule is `root()`, which creates the initial scope that +you destroy manually with a destructor function it returns. -`effect()` creates a reactive scope. `root()` creates a stable scope. +`effect()` creates a reactive scope. Whenever a scope is destroyed, any scope created within that scope is also -destroyed, and so on. This is why all scopes must be created within another -scope, except `root()` which is used to create the initial scope that you can -manually destroy. +destroyed, and so on. -```lua +```luau local root = vide.root local source = vide.source local effect = vide.effect -local function setup() - local count = source(0) +local count = source(0) +local function setup() effect(function() print(count()) end) - - return count end -setup() -- will error since effect() tries to create a reactive scope outside of a stable scope +setup() -- error, effect() tried to create a reactive scope with no stable scope -local count = root(setup) -- ok since effect() was called within a stable scope -count(1) -- prints "1" -``` - -The scope created by `root()` can be destroyed. - -```lua -local function setup() - local count = source(0) - - effect(function() - print(count()) - end) - - return count -end - -local destroy, count = root(setup) +local destroy = root(setup) -- ok since effect() was called in a stable scope count(1) -- prints "1" +count(2) -- prints "2" destroy() -count(2) -- effect is destroyed; no longer prints +count(3) -- reactive scope created by effect() is destroyed, it does not rerun ``` Vide's reactivity can be represented graphically, as a *reactive graph*. @@ -73,12 +57,12 @@ The reactive graph for the above example looks like so: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" } }}%% @@ -90,7 +74,7 @@ subgraph root end ``` -When the stable `root()` is destroyed, the reactive `effect()` +When the stable `root()` scope is destroyed, the reactive `effect()` scope will also be destroyed since it was created within it. This is important because you may have an effect that updates the property of a diff --git a/docs/tut/crash-course/7-stateful-component.md b/docs/tut/crash-course/7-reactive-component.md similarity index 74% rename from docs/tut/crash-course/7-stateful-component.md rename to docs/tut/crash-course/7-reactive-component.md index 493f9bc..339ee4f 100644 --- a/docs/tut/crash-course/7-stateful-component.md +++ b/docs/tut/crash-course/7-reactive-component.md @@ -1,11 +1,9 @@ -# Stateful Components +# Reactive Components -Stateful components in Vide are created using sources and effects - sources to +Reactive components in Vide are created using sources and effects - sources to store the data, and effects to display the data. -## Internal State - -```lua +```luau local create = vide.create local source = vide.source local effect = vide.effect @@ -33,19 +31,13 @@ its internal count, and automatically update its text to reflect that count. Each instance of `Counter()` will maintain its own independent count, since the count source is created inside the component. -## External State - External sources can also be passed into components for them to use. -```lua -local function Counter(props: { count: () -> number }) +```luau +local function CountDisplay(props: { count: () -> number }) local count = props.count - local instance = create "TextButton" { - Activated = function() - count(count() + 1) - end - } + local instance = create "TextLabel" {} effect(function() instance.Text = "count: " .. count() @@ -56,11 +48,11 @@ end local count = source(0) -Counter { +CountDisplay { count = count } -count(1) -- the Counter component will update to display this count +count(1) -- the CountDisplay component will update to display this count ``` Sources can be created internally or passed in from externally, there are no diff --git a/docs/tut/crash-course/8-implicit-effect.md b/docs/tut/crash-course/8-implicit-effect.md index bbd92d4..3363bf6 100644 --- a/docs/tut/crash-course/8-implicit-effect.md +++ b/docs/tut/crash-course/8-implicit-effect.md @@ -3,7 +3,9 @@ Explicitly creating effects to update properties is tedious. You can *implicitly* create an effect to update properties instead. -```lua +::: code-group + +```luau [Implicit Effect] local create = vide.create local source = vide.source @@ -22,6 +24,30 @@ local function Counter() end ``` +```luau [Explicit Effect] +local create = vide.create +local source = vide.source +local effect = vide.effect + +local function Counter() + local count = source(0) + + local instance = create "TextButton" { + Activated = function() + count(count() + 1) + end + } + + effect(function() + instance.Text = "count: " .. count() + end) + + return instance +end +``` + +::: + This example is equivalent to the example seen on the previous page. Instead of explicitly creating an effect, assigning a (non-event) property a @@ -34,7 +60,7 @@ with a number key instead of string key) can return an instance or an array of instances. An effect is automatically created to unparent removed instances and parent new instances on source update. -```lua +```luau local items = source { create "TextLabel" { Text = "A" } } @@ -46,12 +72,12 @@ local function List(props: { children: () -> { Instance } }) } end -local list = List { children = items } -- creates a list with a single text label "A" +local list = List { children = items } -- creates a list with text label "A" items { create "TextLabel" { Text = "B" }, create "TextLabel" { Text = "C" } } --- this will automatically unparent the text label "A", and parent the labels "B" and "C" +-- this will automatically unparent text label "A", and parent labels "B" and "C" ``` diff --git a/docs/tut/crash-course/9-derived-source.md b/docs/tut/crash-course/9-derived-source.md index 12a89b2..694d90b 100644 --- a/docs/tut/crash-course/9-derived-source.md +++ b/docs/tut/crash-course/9-derived-source.md @@ -2,7 +2,7 @@ We have seen the basic way to derive a source: -```lua +```luau local count = source(0) local text = function() @@ -18,7 +18,7 @@ However, in some cases where this source could be used by multiple effects at the same time, the function wrapping the source will needlessly rerun to convert the count into a string for each effect using it. -```lua +```luau local source = vide.source local effect = vide.effect @@ -39,7 +39,7 @@ To avoid this, you can use `derive()` to derive a new source instead. This will run a function in a reactive scope only when a source used inside updated. Reading this derived source multiple times will just return a cached result. -```lua +```luau local source = vide.source local effect = vide.effect local derive = vide.derive @@ -57,7 +57,8 @@ effect(function() text() end) count(1) -- prints "ran" x1 ``` -`derive()` must also be called within a stable scope, just like `effect()`. +Because `derive()` creates a reactive scope, it must be called within a stable +scope, just like `effect()`. If the recalculated value is the same as the old value, the derived source will not rerun the effects using it. @@ -68,12 +69,12 @@ The reactive graph for the above example: %%{init: { "theme": "base", "themeVariables": { - "primaryColor": "#1B1B1F", + "primaryColor": "#111720", "primaryTextColor": "#fff", - "primaryBorderColor": "#1B1B1F", + "primaryBorderColor": "#111720", "lineColor": "#79B8FF", - "tertiaryColor": "#161618", - "tertiaryBorderColor": "#161618" + "tertiaryColor": "#0d131b", + "tertiaryBorderColor": "#0d131b" } }}%% @@ -86,7 +87,7 @@ end ``` Deriving a source in this manner is similar to creating an effect to update -another source. You should never manually do this using an effect however. +another source. You should avoid doing this using an effect however. Improper usage could accidently create infinite loops in the reactive graph. Always favour deriving when you need one source to update based on another source. diff --git a/init.luau b/init.luau new file mode 100644 index 0000000..a9ae8e0 --- /dev/null +++ b/init.luau @@ -0,0 +1,10 @@ +local vide = require "@self/src/lib" + +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties + +return vide diff --git a/pesde.toml b/pesde.toml new file mode 100644 index 0000000..94c8ede --- /dev/null +++ b/pesde.toml @@ -0,0 +1,15 @@ +name = "centau/vide" +version = "0.4.1" +description = "A reactive Luau library for creating UI." +authors = ["centau"] +repository = "https://github.com/centau/vide" +license = "MIT" +includes = ["src/*", "README.md", "pesde.toml"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" + +[indices] +default = "https://github.com/pesde-pkg/index" diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..4bc51cb --- /dev/null +++ b/rokit.toml @@ -0,0 +1,9 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +pesde = "pesde-pkg/pesde@0.6.2+registry.0.2.2" +wally = "upliftgames/wally@0.3.2" +rojo = "rojo-rbx/rojo@7.4.4" diff --git a/src/apply.luau b/src/apply.luau index 0f4586e..c05fedf 100644 --- a/src/apply.luau +++ b/src/apply.luau @@ -1,24 +1,20 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never -local Vector2 = game and Vector2 or require "test/mock".Vector2 :: never -local UDim2 = game and UDim2 or require "test/mock".UDim2 :: never +local typeof = game and typeof or require "../test/mock".typeof :: never -local flags = require(script.Parent.flags) -local throw = require(script.Parent.throw) -local bind = require(script.Parent.bind) -local _, is_action = require(script.Parent.action)() -local graph = require(script.Parent.graph) +local flags = require "./flags" +local implicit_effect = require "./implicit_effect" +local _, is_action = require "./action"() +local graph = require "./graph" type Node = graph.Node type Array = { V } type ArrayOrV = {ArrayOrV} | V type Map = { [K]: V } -local free_caches: { +type Cache = { -- event listeners to connect after properties are set - events: Map< - string, -- event name - () -> () -- listener + events: Array< + | string -- 1. event name + | () -> () -- 2. listener >, -- actions to run after events are connected @@ -27,30 +23,34 @@ local free_caches: { Array<(Instance) -> ()> -- action callbacks >, + -- what to parent the instance to after running actions + parent: unknown, + -- cache to detect duplicate property setting at same nesting depth nested_debug: Map< number, -- depth Map -- set of property names >, - -- use stack instead of recursive function to process nesting layers one at time - -- deeper-nested properties take precedence over shallower-nested ones -- each nested layer occupies two indexes: 1. table ref 2. nested depth -- e.g. { t1 = { t3 = {} }, t2 = {} } -> { t1, 1, t2, 1, t3, 2 } nested_stack: { {} | number } -}? +} -local function borrow_caches(): typeof(assert(free_caches)) - if free_caches then - local caches = free_caches :: typeof(assert(free_caches)) - free_caches = nil - return caches +local free_cache: Cache? + +local function borrow_cache(): Cache + if free_cache then + local cache = free_cache + free_cache = nil + return cache else return { events = {}, actions = setmetatable({} :: any, { -- lazy init __index = function(self, i) self[i] = {}; return self[i] end }), + parent = nil, nested_debug = setmetatable({} :: any, { __index = function(self, i: number) self[i] = {}; return self[i] end }), @@ -59,36 +59,61 @@ local function borrow_caches(): typeof(assert(free_caches)) end end -local function return_caches(caches: typeof(free_caches) ) - free_caches = caches +local function return_cache(cache: Cache ) + free_cache = cache end --- map of datatype names to class default constructor for aggregate init -local aggregates = {} -for name, class in { - CFrame = CFrame, - Color3 = Color3, - UDim = UDim, - UDim2 = UDim2, - Vector2 = Vector2, - Vector3 = Vector3, - Rect = Rect -} :: Map do - aggregates[name] = class.new +local function process_properties(properties: Map, instance: Instance, cache: Cache, depth: number) + for property, value in properties do + if type(property) == "string" then + if flags.strict then -- check for duplicate property assignment at nesting depth + if cache.nested_debug[depth][property] then + error(`duplicate property {property} at depth {depth}`, 0) + end + cache.nested_debug[depth][property] = true + end + + if property == "Parent" then + cache.parent = value + continue + end + + if type(value) == "function" then + if typeof((instance :: any)[property]) == "RBXScriptSignal" then + table.insert(cache.events, property) -- add event name to buffer + table.insert(cache.events, value :: () -> ()) -- add event listener to buffer + else + implicit_effect.property(instance, property, value :: () -> ()) -- create implicit effect for property + end + else + (instance :: any)[property] = value -- set property + end + elseif type(property) == "number" then + if type(value) == "function" then + implicit_effect.children(instance, value :: () -> ArrayOrV) -- bind children + elseif type(value) == "table" then + if is_action(value) then + table.insert(cache.actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer + elseif flags.defer_nested_properties then + table.insert(cache.nested_stack, value :: {}) + table.insert(cache.nested_stack, depth + 1) -- push table to stack for later processing + else + process_properties(value :: Map, instance, cache, depth + 1) + end + elseif type(value) == "userdata" then + (value :: Instance).Parent = instance -- parent child + end + end + end end -- applies table of nested properties to an instance using full vide semantics local function apply(instance: T & Instance, properties: { [unknown]: unknown }): T if not properties then - throw("attempt to call a constructor returned by create() with no properties") + error "attempt to call a constructor returned by create() with no properties" end - local strict = flags.strict - - -- queue parent assignment if any for last - local parent: unknown = properties.Parent - - local caches = borrow_caches() + local caches = borrow_cache() local events = caches.events local actions = caches.actions local nested_debug = caches.nested_debug @@ -97,79 +122,39 @@ local function apply(instance: T & Instance, properties: { [unknown]: unknown -- process all properties local depth = 1 repeat - for property, value in properties do - if property == "Parent" then continue end - - if type(property) == "string" then - if strict then -- check for duplicate prop assignment at nesting depth - if nested_debug[depth][property] then - throw(`duplicate property {property} at depth {depth}`) - end - nested_debug[depth][property] = true - end - - if type(value) == "table" then -- attempt aggregate init - local ctor = aggregates[typeof((instance :: any)[property])] - if ctor == nil then - throw(`cannot aggregate type {typeof(value)} for property {property}`) - end - (instance :: any)[property] = ctor(unpack(value :: {})) - elseif type(value) == "function" then - if typeof((instance :: any)[property]) == "RBXScriptSignal" then - events[property] = value :: () -> () -- add event to buffer - else - bind.property(instance, property, value :: () -> ()) -- bind property - end - else - (instance :: any)[property] = value -- set property - end - elseif type(property) == "number" then - if type(value) == "function" then - bind.children(instance, value :: () -> ArrayOrV) -- bind children - elseif type(value) == "table" then - if is_action(value) then - table.insert(actions[(value :: any).priority], (value :: any).callback :: () -> ()) -- add action to buffer - else - table.insert(nested_stack, value :: {}) - table.insert(nested_stack, depth + 1) -- push table to stack for later processing - end - else - (value :: Instance).Parent = instance -- parent child - end - end - end - + process_properties(properties, instance, caches, depth) depth = table.remove(nested_stack) :: number properties = table.remove(nested_stack) :: {} - until not properties - for event, listener in next, events do - (instance :: any)[event]:Connect(listener) + for i = 1, #events, 2 do + local event_name = events[i] + local event_listener = events[i + 1] + ;(instance :: any)[event_name]:Connect(event_listener) end - for _, queued in next, actions do - for _, callback in next, queued do + for _, queued in actions do + for _, callback in queued do callback(instance) end end - -- finally set parent if any + local parent = caches.parent if parent then if type(parent) == "function" then - bind.parent(instance, parent :: () -> Instance) + implicit_effect.parent(instance, parent :: () -> Instance) else instance.Parent = parent :: Instance end end - -- clear caches table.clear(events) - for _, queued in next, actions do table.clear(queued) end - if strict then table.clear(nested_debug) end + for _, queued in actions do table.clear(queued) end + caches.parent = nil + if flags.strict then table.clear(nested_debug) end table.clear(nested_stack) - return_caches(caches) + return_cache(caches) return instance end diff --git a/src/batch.luau b/src/batch.luau index 45ab038..e3e6d40 100644 --- a/src/batch.luau +++ b/src/batch.luau @@ -1,8 +1,5 @@ -if not game then script = require "test/relative-string" end - -local flags = require(script.Parent.flags) -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local flags = require "./flags" +local graph = require "./graph" local function batch(setter: () -> ()) local already_batching = flags.batch @@ -13,14 +10,14 @@ local function batch(setter: () -> ()) from = graph.get_update_queue_length() end - local ok, err: string? = pcall(setter) + local ok, err: string? = xpcall(setter, debug.traceback) if not already_batching then flags.batch = false graph.flush_update_queue(from) end - if not ok then throw(`error occured while batching updates: {err}`) end + if not ok then error(`error occured while batching updates: {err}`, 0) end end return batch diff --git a/src/bind.luau b/src/bind.luau deleted file mode 100644 index 3a016a6..0000000 --- a/src/bind.luau +++ /dev/null @@ -1,105 +0,0 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) -type Node = graph.Node -local create_node = graph.create_node -local assert_stable_scope = graph.assert_stable_scope -local evaluate_node = graph.evaluate_node - -function create_implicit_effect(updater: (T) -> T, binding: T) - evaluate_node(create_node(assert_stable_scope(), updater, binding)) -end - -type PropertyBinding = { - instance: Instance, - property: string, - source: () -> unknown -} - -local function update_property_effect(p: PropertyBinding) - (p.instance :: any)[p.property] = p.source() - return p -end - -type ParentBinding = { - instance: Instance, - parent: () -> Instance -} - -local function update_parent_effect(p: ParentBinding) - p.instance.Parent = p.parent() - return p -end - -type ChildrenBinding = { - instance: Instance, - cur_children_set: { [Instance]: true }, - new_children_set: { [Instance]: true }, - children: () -> Instance | { Instance } -} - -type ArrayOrV = V | { V } -local function update_children_effect(p: ChildrenBinding) - local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update - local new_child_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update - - local new_children = p.children() -- all (and only) children that should be parented after this update - - if type(new_children) ~= "table" then - new_children = { new_children } - end - - local function process_child(child: ArrayOrV) - if type(child) == "table" then - for _, child in next, child do - process_child(child) - end - else - if new_child_set[child] then return end -- stops redundant reparenting - - new_child_set[child] = true -- record child set from this update - if not cur_children_set[child] then - child.Parent = p.instance -- if child wasn't already parented then parent it - else - cur_children_set[child] = nil -- remove child from cache if it was already in cache - end - end - end - - process_child(new_children) - - for child in next, cur_children_set do - child.Parent = nil -- unparent all children that weren't in the new children set - end - - table.clear(cur_children_set) -- clear cache, preserve capacity - p.cur_children_set, p.new_children_set = new_child_set, cur_children_set - - return p -end - -return { - property = function(instance, property, source) - return create_implicit_effect(update_property_effect, { - instance = instance, - property = property, - source = source - }) - end, - - parent = function(instance, parent) - return create_implicit_effect(update_parent_effect, { - instance = instance, - parent = parent - }) - end, - - children = function(instance, children) - return create_implicit_effect(update_children_effect, { - instance = instance, - cur_children_set = {}, - new_children_set = {}, - children = children - }) - end -} diff --git a/src/branch.luau b/src/branch.luau new file mode 100644 index 0000000..2138d06 --- /dev/null +++ b/src/branch.luau @@ -0,0 +1,40 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local push_scope = graph.push_scope +local pop_scope = graph.pop_scope +local destroy = graph.destroy +local get_scope = graph.get_scope + +local function branch(fn: () -> T): (() -> (), T) + local current = get_scope() + if not current then + error(`cannot use branch() outside a stable or reactive scope`, 0) + end + + local parent = current.owner + if not parent then + error(`current scope is not owned by a scope`, 0) + end + + local node = create_node(parent, false, false) + + local destroy = function() + destroy(node) + end + + push_scope(node) + + local ok, result = xpcall(fn, debug.traceback) + + pop_scope() + + if not ok then + destroy() + error(`error while running branch():\n\n{result}`, 0) + end + + return destroy, result +end + +return branch diff --git a/src/changed.luau b/src/changed.luau index 519a554..063f7e9 100644 --- a/src/changed.luau +++ b/src/changed.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local action = require(script.Parent.action)() -local cleanup = require(script.Parent.cleanup) +local action = require "./action"() +local cleanup = require "./cleanup" local function changed(property: string, callback: (T) -> ()) return action(function(instance) diff --git a/src/cleanup.luau b/src/cleanup.luau index 8449fcc..6c46bbf 100644 --- a/src/cleanup.luau +++ b/src/cleanup.luau @@ -1,27 +1,26 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof :: never +local typeof = game and typeof or require "../test/mock".typeof :: never -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./graph" local get_scope = graph.get_scope local push_cleanup = graph.push_cleanup local function helper(obj: any) return if typeof(obj) == "RBXScriptConnection" then function() obj:Disconnect() end + elseif type(obj) == "thread" then function() task.cancel(obj) end elseif typeof(obj) == "Instance" then function() obj:Destroy() end elseif obj.destroy then function() obj:destroy() end elseif obj.disconnect then function() obj:disconnect() end elseif obj.Destroy then function() obj:Destroy() end elseif obj.Disconnect then function() obj:Disconnect() end - else throw("cannot cleanup given object") + else error "cannot cleanup given object" end local function cleanup(value: unknown) local scope = get_scope() if not scope then - throw "cannot cleanup outside a stable or reactive scope" + error "cannot cleanup outside a stable or reactive scope" end; assert(scope) if type(value) == "function" then @@ -36,6 +35,7 @@ type Disconnectable = { disconnect: (any) -> () } | { Disconnect: (any) -> () } return cleanup :: ( (callback: () -> ()) -> () ) & + ( (thread: thread) -> () ) & ( (instance: Destroyable) -> () ) & ( (connection: Disconnectable) -> () ) & ( (instance: Instance) -> () ) & diff --git a/src/context.luau b/src/context.luau index fc0e8c1..2ce3eff 100644 --- a/src/context.luau +++ b/src/context.luau @@ -1,7 +1,4 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node local get_scope = graph.get_scope @@ -9,7 +6,7 @@ local push_scope = graph.push_scope local pop_scope = graph.pop_scope local set_context = graph.set_context -export type Context = (() -> T) & ((T, () -> ()) -> ()) +export type Context = (() -> T) & ((T, () -> U) -> U) local nil_symbol = newproxy() local count = 0 @@ -21,7 +18,7 @@ local function context(...: T): Context local has_default = select("#", ...) > 0 local default_value = ... - return function(...) + return function(...): any -- todo: fix type error local scope: Node? | false = get_scope() if select("#", ...) == 0 then -- get @@ -46,10 +43,10 @@ local function context(...: T): Context if has_default ~= nil then return default_value else - throw("attempt to get context when no context is set and no default context is set") + error("attempt to get context when no context is set and no default context is set", 0) end else -- set - if not scope then return throw("attempt to set context outside of a vide scope") end + if not scope then return error("attempt to set context outside of a vide scope", 0) end local value, component = ... @@ -64,8 +61,10 @@ local function context(...: T): Context pop_scope() if not ok then - throw(`error while running context:\n\n{result}`) + error(`error while running context:\n\n{result}`, 0) end + + return result end return nil :: any diff --git a/src/create.luau b/src/create.luau index 7adfc68..120d17d 100644 --- a/src/create.luau +++ b/src/create.luau @@ -1,84 +1,119 @@ -if not game then script = require "test/relative-string" end -local typeof = game and typeof or require "test/mock".typeof:: never -local Instance = game and Instance or require "test/mock".Instance :: never +local typeof = game and typeof or require "../test/mock".typeof :: never +local Instance = game and Instance or require "../test/mock".Instance :: never -local throw = require(script.Parent.throw) -local defaults = require(script.Parent.defaults) -local apply = require(script.Parent.apply) +local defaults = require "./defaults" +local apply = require "./apply" +local flags = require "./flags" -local ctor_cache = {} :: { [string]: () -> Instance } - -setmetatable(ctor_cache :: any, { - __index = function(self, class) +local function create_constructor_for_class(class: string): ({ [unknown]: unknown }) -> Instance + local function constructor(properties: { [unknown]: unknown }): Instance local ok, instance: Instance = pcall(Instance.new, class :: any) - if not ok then throw(`invalid class name, could not create instance of class { class }`) end + if not ok then error(`invalid class name {class}`, 0) end - local default: { [string]: unknown }? = defaults[class] - if default then - for i, v in next, default do - (instance :: any)[i] = v + if flags.defaults then + local default: { [string]: unknown }? = defaults[class] + if default then + for i, v in default do + (instance :: any)[i] = v + end end end - local function ctor(properties: Props): Instance - return apply(instance:Clone(), properties) - end - - self[class] = ctor - return ctor + return apply(instance, properties) end -}) -local function create_instance(class: string) - return ctor_cache[class] + return constructor end -local function clone_instance(instance: Instance) - return function(properties: Props): Instance - local clone = instance:Clone() - if not clone then throw "attempt to clone a non-archivable instance" end - return apply(clone, properties) - end -end +local constructor_cache = {} :: { [string]: ({ [unknown]: unknown }) -> Instance } -local function create(class_or_instance: string|Instance): (Props) -> Instance +-- todo: remove support for different overloads +local function create(class_or_instance: string|Instance, properties: { [unknown]: unknown }?): unknown + if type(class_or_instance) ~= "string" and typeof(class_or_instance) ~= "Instance" then + error("bad argument #1, expected string or instance, got " .. typeof(class_or_instance), 0) + end + + local constructor: ({ [unknown]: unknown }) -> Instance if type(class_or_instance) == "string" then - return create_instance(class_or_instance) - elseif typeof(class_or_instance) == "Instance" then - return clone_instance(class_or_instance) + constructor = constructor_cache[class_or_instance] + if not constructor then + constructor = create_constructor_for_class(class_or_instance) + constructor_cache[class_or_instance] = constructor + end else - throw("bad argument #1, expected string or instance, got " .. typeof(class_or_instance)) - return nil :: never + constructor = function(props) + local clone = assert(class_or_instance:Clone(), "attempt to clone a non-archivable instance") + return apply(clone, props) + end end + + return if properties + then constructor(properties) + else constructor 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 ) +export type Instances = { + Folder: Folder, + BillboardGui: BillboardGui, + CanvasGroup: CanvasGroup, + Frame: Frame, + ImageButton: ImageButton, + ImageLabel: ImageLabel, + ScreenGui: ScreenGui, + ScrollingFrame: ScrollingFrame, + SurfaceGui: SurfaceGui, + TextBox: TextBox, + TextButton: TextButton, + TextLabel: TextLabel, + UIAspectRatioConstraint: UIAspectRatioConstraint, + UICorner: UICorner, + UIGradient: UIGradient, + UIGridLayout: UIGridLayout, + UIListLayout: UIListLayout, + UISizeConstraint: UISizeConstraint, + UITextSizeConstraint: UITextSizeConstraint, + UIScale: UIScale, + UIPadding: UIPadding, + UIStroke: UIStroke, + UIFlexItem: UIFlexItem, + UIPageLayout: UIPageLayout, + UITableLayout: UITableLayout, + VideoFrame: VideoFrame, + ViewportFrame: ViewportFrame, + ProximityPrompt: ProximityPrompt, + UIDragDetector: UIDragDetector, + Camera: Camera, + WorldModel: WorldModel, +} + +export type function Properties(instance: type?) + local properties = types.newtable() + + while instance do + for i, v in instance:properties() do + local connector = v.read and v.read.tag == "table" and v.read:readproperty(types.singleton("Connect")) + if connector then + local params = connector:parameters().head + if not params then continue end + local listener = params[2] + if not listener then continue end + properties:setproperty(i, types.optional(listener)) + elseif v.write then + properties:setproperty(i, types.optional(types.unionof( + v.write, + types.newfunction({}, { head = { v.write } }) + ))) + end + end + + instance = instance:readparent() + end + + properties:setindexer(types.number, types.any) + + return properties +end + +type Create = (Name|keyof|"") -> (Properties>) -> index + +return create :: Create diff --git a/src/defaults.luau b/src/defaults.luau index 03badd6..77040ff 100644 --- a/src/defaults.luau +++ b/src/defaults.luau @@ -1,11 +1,10 @@ -local Enum = game and Enum or require "test/mock".Enum :: never -local Color3 = game and Color3 or require "test/mock".Color3 :: never -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never +local Enum = game and Enum or require "../test/mock".Enum :: never +local Color3 = game and Color3 or require "../test/mock".Color3 :: never return { Part = { Material = Enum.Material.SmoothPlastic, - Size = Vector3.new(1, 1, 1), + Size = vector.create(1, 1, 1), Anchored = true }, @@ -111,4 +110,4 @@ return { BorderColor3 = Color3.new(0, 0, 0), BorderSizePixel = 0 } -} +} :: { [string]: { [string]: unknown} } diff --git a/src/derive.luau b/src/derive.luau index fb824a4..b941185 100644 --- a/src/derive.luau +++ b/src/derive.luau @@ -1,8 +1,6 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" local create_node = graph.create_node -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node @@ -12,7 +10,7 @@ local function derive(source: () -> T): () -> T evaluate_node(node) return function() - push_child_to_scope(node) + push_scope_as_child_of(node) return node.cache end end diff --git a/src/effect.luau b/src/effect.luau index 3acab21..5a2cd25 100644 --- a/src/effect.luau +++ b/src/effect.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" local create_node = graph.create_node local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node diff --git a/src/flags.luau b/src/flags.luau index cc2d2f8..be48232 100644 --- a/src/flags.luau +++ b/src/flags.luau @@ -4,4 +4,9 @@ end local is_O2 = inline_test() ~= "inline_test" -return { strict = not is_O2, batch = false } +return { + strict = not is_O2, + defaults = true, + defer_nested_properties = true, + batch = false, +} diff --git a/src/graph.luau b/src/graph.luau index 9648180..fd22489 100644 --- a/src/graph.luau +++ b/src/graph.luau @@ -1,7 +1,4 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) +local flags = require "./flags" export type SourceNode = { cache: T, @@ -24,9 +21,23 @@ export type Node = { local scopes = { n = 0 } :: { [number]: Node, n: number } -- scopes stack +local function efn(err: string) + local trace = debug.traceback(err, 2) + + if string.find(err, "^effect error stacktrace") then -- if effect error is nested + trace = string.gsub(" " .. trace, "\n", function() -- indent entire error + return "\n " + end) + end + + trace ..= "\nsource update stacktrace:" +return trace +end + local function ycall(fn: (T) -> U, arg: T): (boolean, string|U) + local thread = coroutine.create(xpcall) - local function efn(err: string) return debug.traceback(err, 3) end + --local function efn(err: string) return debug.traceback(err, 3) end local resume_ok, run_ok, result = coroutine.resume(thread, fn, efn, arg) assert(resume_ok) @@ -47,9 +58,9 @@ local function assert_stable_scope(): Node if not scope then local caller_name = debug.info(2, "n") - return throw(`cannot use {caller_name}() outside a stable or reactive scope`) + return error(`cannot use {caller_name}() outside a stable or reactive scope`, 0) elseif scope.effect then - throw("cannot create a new reactive scope inside another reactive scope") + error("cannot create a new reactive scope inside another reactive scope", 0) end return scope @@ -82,9 +93,9 @@ end local function flush_cleanups(node: Node) if node.cleanups then - for _, fn in next, node.cleanups do - local ok, err: string? = pcall(fn) - if not ok then throw(`cleanup error: {err}`) end + for _, fn in node.cleanups do + local ok, err: string? = xpcall(fn, debug.traceback) + if not ok then error(`cleanup error: {err}`, 0) end end table.clear(node.cleanups) @@ -108,6 +119,10 @@ local function unparent(node: Node) end local function destroy(node: Node) + if flags.strict and table.find(scopes, node) then + error("attempt to destroy an active scope", 0) + end + flush_cleanups(node) unparent(node) @@ -133,6 +148,10 @@ local update_queue = { n = 0 } :: { n: number, [number]: Node } local function evaluate_node(node: Node) if flags.strict then + if table.find(scopes, node) then + error("a scope, that should rerun due to the update of a source, is already active", 0) + end + local initial_value = node.cache for i = 1, 2 do @@ -148,7 +167,7 @@ local function evaluate_node(node: Node) if not ok then table.clear(update_queue) update_queue.n = 0 - throw(`effect stacktrace:\n{new_value :: string}`) + error(`effect error stacktrace\n{new_value :: string}`, 0) end node.cache = new_value :: T @@ -168,7 +187,7 @@ local function evaluate_node(node: Node) if not ok then table.clear(update_queue) update_queue.n = 0 - throw(`effect stacktrace:\n{new_value}\n`) + error(`effect error:\n{new_value}\n`, 0) end node.cache = new_value @@ -230,7 +249,7 @@ local function update_descendants(root: SourceNode) update_queue.n = n0 end -local function push_child_to_scope(node: SourceNode) +local function push_scope_as_child_of(node: SourceNode) local scope = get_scope() if scope and scope.effect then -- do not track nodes with no effect push_child(node, scope) @@ -287,7 +306,7 @@ return table.freeze { push_cleanup = push_cleanup, destroy = destroy, flush_cleanups = flush_cleanups, - push_child_to_scope = push_child_to_scope, + push_scope_as_child_of = push_scope_as_child_of, update_descendants = update_descendants, push_child = push_child, create_node = create_node, @@ -296,5 +315,7 @@ return table.freeze { flush_update_queue = flush_update_queue, get_update_queue_length = get_update_queue_length, set_context = set_context, - scopes = scopes + scopes = scopes, + + q = update_queue } diff --git a/src/implicit_effect.luau b/src/implicit_effect.luau new file mode 100644 index 0000000..6fed9bc --- /dev/null +++ b/src/implicit_effect.luau @@ -0,0 +1,125 @@ +local graph = require "./graph" +type Node = graph.Node +local create_node = graph.create_node +local assert_stable_scope = graph.assert_stable_scope +local get_scope = graph.get_scope +local evaluate_node = graph.evaluate_node +local push_cleanup = graph.push_cleanup + +local function update_property_effect(p: { + instance: Instance, + property: string, + source: () -> unknown +}) + (p.instance :: any)[p.property] = p.source() + return p +end + +local function update_parent_effect(p: { + instance: Instance, + source: () -> Instance +}) + p.instance.Parent = p.source() + return p +end + +-- todo: investigate if "count" method used in indexes() and values() can improve performance here +local function update_children_effect(p: { + instance: Instance, + cur_children_set: { [Instance]: true }, + new_children_set: { [Instance]: true }, + source: () -> Instance | { Instance } +}) + local cur_children_set: { [Instance]: true } = p.cur_children_set -- cache of all children parented before update + local new_children_set: { [Instance]: true } = p.new_children_set -- cache of all children parented after update + + local new_children = p.source() -- all (and only) children that should be parented after this update + + local function process_child(child: Instance | { Instance }) + if type(child) == "userdata" then + if new_children_set[child] then return end -- stops redundant reparenting + + new_children_set[child] = true -- record child set from this update + + if not cur_children_set[child] then + child.Parent = p.instance -- if child wasn't already parented then parent it + else + cur_children_set[child] = nil -- remove child from cache if it was already in cache + end + elseif type(child) == "table" then + for _, child in child do + process_child(child) + end + elseif type(child) == "function" then + local node = create_node(assert(get_scope()), update_children_effect, { + instance = p.instance, + cur_children_set = {}, + new_children_set = {}, + source = child + }) + + evaluate_node(node) + + push_cleanup(assert(get_scope()), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + end + end + + process_child(new_children) + + for child in cur_children_set do + child.Parent = nil -- unparent all children that weren't in the new children set + end + + table.clear(cur_children_set) -- clear cache, preserve capacity + p.cur_children_set, p.new_children_set = new_children_set, cur_children_set + + return p +end + +return { + property = function(instance, property, source) + local node = create_node(assert_stable_scope(), update_property_effect, { + instance = instance, + property = property, + source = source + }) + + evaluate_node(node) + + return node + end, + + parent = function(instance, parent) + local node = create_node(assert_stable_scope(), update_parent_effect, { + instance = instance, + source = parent + }) + + evaluate_node(node) + + return node + end, + + children = function(instance, children) + local node = create_node(assert_stable_scope(), update_children_effect, { + instance = instance, + cur_children_set = {}, + new_children_set = {}, + source = children + }) + + evaluate_node(node) + + push_cleanup(assert_stable_scope(), function() + for child in node.cache.cur_children_set do + child.Parent = nil + end + end) + + return node + end +} diff --git a/src/indexes.luau b/src/indexes.luau new file mode 100644 index 0000000..fe8416e --- /dev/null +++ b/src/indexes.luau @@ -0,0 +1,121 @@ +local flags = require "./flags" +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () + +type Array = { T } +type Map = { [K]: V } +type Source = () -> T + +local function indexes( + input: Source>, + component: (Source, K, Source) -> (Obj, ...number) +): Source> + local update_count = 0 + local scopes = {} :: Map (), + object: Obj, + value: V?, + value_source: (V?) -> V, + count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, + }> + + local output = source({} :: Array) + local function update_output() + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output(objects) + end + + effect(function() + local data = input() + + local count = update_count + update_count += 1 + + local children_need_update = false -- set to true if a scope is created or destroyed + + -- create or update scopes + for i, v in data do + local scope = scopes[i] + + if scope == nil then -- create new scope and create component + local value_source = source(v) + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(value_source, i, present) + delay = t + return object + end) + + present(true) + + children_need_update = true + + scopes[i] = { + destroy = destroy, + object = object, + value = v, + value_source = value_source, + count = count, + delay = delay or 0, + present = present, + timeout = nil, + } + else -- update scope + scope.count = count + + if scope.value ~= v then + if scope.timeout then -- index is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) + end + + scope.value = v + scope.value_source(v) + end + end + end + + -- destroy scopes + for i, scope in scopes do + if scope.count < count then -- if count is not latest then index is no longer in the input table + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[i] = nil + children_need_update = true + else + scope.value = nil -- set to nil for the `scope.value ~= v` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[i] = nil + update_output() + end) + end + end + end + end + + if children_need_update then + update_output() + end + end) + + return output +end + +return indexes diff --git a/src/init.luau b/src/init.luau index d2872c7..c0fbe0e 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,118 +1,12 @@ --------------------------------------------------------------------------------- --- vide.luau --- v0.3.0 --------------------------------------------------------------------------------- +assert(game, "when using vide outside of Roblox, require lib.luau instead") -if not game then script = require "test/relative-string" end +local vide = require("@self/lib") -local root = require(script.root) -local mount = require(script.mount) -local create = require(script.create) -local apply = require(script.apply) -local source = require(script.source) -local effect = require(script.effect) -local derive = require(script.derive) -local cleanup = require(script.cleanup) -local untrack = require(script.untrack) -local read = require(script.read) -local batch = require(script.batch) -local context = require(script.context) -local switch = require(script.switch) -local show = require(script.show) -local indexes, values = require(script.maps)() -local spring, update_springs = require(script.spring)() -local action = require(script.action)() -local changed = require(script.changed) -local throw = require(script.throw) -local flags = require(script.flags) - -export type Source = source.Source -export type source = Source -export type Context = context.Context -export type context = Context - -local function step(dt: number) - if game then - debug.profilebegin("VIDE STEP") - debug.profilebegin("VIDE SPRING") - end - - update_springs(dt) - - if game then - debug.profileend() - debug.profileend() - end -end - -local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) - task.defer(step, dt) -end) - -local vide = { - -- core - root = root, - mount = mount, - create = create, - source = source, - effect = effect, - derive = derive, - switch = switch, - show = show, - indexes = indexes, - values = values, - - -- util - cleanup = cleanup, - untrack = untrack, - read = read, - batch = batch, - context = context, - - -- animations - spring = spring, - - -- actions - action = action, - changed = changed, - - -- flags - strict = (nil :: any) :: boolean, - - -- temporary - apply = function(instance: Instance) - return function(props: { [any]: any }) - apply(instance, props) - return instance - end - end, - - -- runtime - step = function(dt: number) - if stepped then - stepped:Disconnect() - stepped = nil - end - step(dt) - end -} - -setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if index == "strict" then - return flags.strict - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end, - - __newindex = function(_, index: unknown, value: unknown) - if index == "strict" then - flags.strict = value :: boolean - else - throw(`{tostring(index)} is not a valid member of vide`) - end - end -}) +export type source = vide.source +export type Source = vide.Source +export type context = vide.context +export type Context = vide.Context +export type Instances = vide.Instances +export type Properties = vide.Properties return vide diff --git a/src/lib.luau b/src/lib.luau new file mode 100644 index 0000000..a8a3885 --- /dev/null +++ b/src/lib.luau @@ -0,0 +1,122 @@ +local version = { major = 0, minor = 4, patch = 1 } + +local root = require "./root" +local branch = require "./branch" +local mount = require "./mount" +local create = require "./create" +local apply = require "./apply" +local source = require "./source" +local effect = require "./effect" +local derive = require "./derive" +local cleanup = require "./cleanup" +local untrack = require "./untrack" +local read = require "./read" +local batch = require "./batch" +local context = require "./context" +local switch = require "./switch" +local show = require "./show" +local indexes = require "./indexes" +local values = require "./values" +local spring, update_springs = require "./spring"() +local action = require "./action"() +local changed = require "./changed" +local timeout, update_timeouts = require "./timeout"() +local flags = require "./flags" + +export type Source = source.Source +export type source = Source +export type Context = context.Context +export type context = Context +export type Instances = create.Instances +export type Properties = create.Properties + +local function step(dt: number) + if game then debug.profilebegin("VIDE STEP") end + + if game then debug.profilebegin("VIDE SPRING") end + update_springs(dt) + if game then debug.profileend() end + + if game then debug.profilebegin("VIDE SCHEDULER") end + update_timeouts(dt) + if game then debug.profileend() end + + if game then debug.profileend() end +end + +local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) + task.defer(step, dt) +end) + +local vide = { + version = version, + + -- core + root = root, + --branch = branch, + mount = mount, + create = create, + source = source, + effect = effect, + derive = derive, + switch = switch, + show = show, + indexes = indexes, + values = values, + + -- util + cleanup = cleanup, + untrack = untrack, + read = read, + batch = batch, + context = context, + + -- animations + spring = spring, + + -- actions + action = action, + changed = changed, + + -- flags + strict = (nil :: any) :: boolean, + defaults = (nil :: any) :: boolean, + defer_nested_properties = (nil :: any) :: boolean, + + -- temporary + apply = function(instance: Instance) + return function(props: { [any]: any }) + apply(instance, props) + return instance + end + end, + + -- runtime + step = function(dt: number) + if stepped then + stepped:Disconnect() + stepped = nil + end + step(dt) + end +} + +setmetatable(vide :: any, { + __index = function(_, index: unknown): () + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide`, 0) + else + return flags[index] + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide, 0`) + else + flags[index] = value + end + end +}) + +return vide diff --git a/src/maps.luau b/src/maps.luau deleted file mode 100644 index 23c1eb3..0000000 --- a/src/maps.luau +++ /dev/null @@ -1,219 +0,0 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local flags = require(script.Parent.flags) -local graph = require(script.Parent.graph) -type Node = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local create_source_node = graph.create_source_node -local push_child_to_scope = graph.push_child_to_scope -local update_descendants = graph.update_descendants -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope -local evaluate_node = graph.evaluate_node -local destroy = graph.destroy - -type Map = { [K]: V } - -local function check_primitives(t: {}) - if not flags.strict then return end - - for _, v in next, t do - if type(v) == "table" or type(v) == "userdata" or type(v) == "function" then continue end - throw("table source map cannot return primitives") - end -end - -local function indexes(input: () -> Map, transform: (() -> VI, K) -> VO): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) - - local input_cache = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local remove_queue = {} :: { K } - local scopes = {} :: Map> - - local function update_children(data) - -- queue removed values - for i in next, input_cache do - if data[i] == nil then - table.insert(remove_queue, i) - end - end - - -- remove queued values - for _, i in next, remove_queue do - destroy(scopes[i]) - - input_cache[i] = nil - output_cache[i] = nil - input_nodes[i] = nil - scopes[i] = nil - end - - table.clear(remove_queue) - - push_scope(subowner) - - -- process new or changed values - for i, v in next, data do - local cv = input_cache[i] - - if cv ~= v then - if cv == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - scopes[i] = scope :: Node - - local node = create_source_node(v) - - push_scope(scope) - - local ok, result = pcall(transform, function() - push_child_to_scope(node) - return node.cache - end, i) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) - end - - input_nodes[i] = node - output_cache[i] = result - else -- update source - input_nodes[i].cache = v - update_descendants(input_nodes[i]) - end - - input_cache[i] = v - end - end - - pop_scope() - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(owner, function() - return update_children(input()) - end, false :: any) - - evaluate_node(node) - - return function() - push_child_to_scope(node) - return node.cache - end -end - -local function values(input: () -> Map, transform: (VI, () -> K) -> VO): () -> { VO } - local owner = assert_stable_scope() - local subowner = create_node(owner, false, false) - - local cur_input_cache_up = {} :: Map - local new_input_cache_up = {} :: Map - local output_cache = {} :: Map - local input_nodes = {} :: Map> - local scopes = {} :: Map> - - local function update_children(data: Map) - local cur_input_cache, new_input_cache = cur_input_cache_up, new_input_cache_up - - if flags.strict then - local cache = {} - for _, v in next, data do - if cache[v] ~= nil then - throw "duplicate table value detected" - end - cache[v] = true - end - end - - push_scope(subowner) - - -- process data - for i, v in next, data do - new_input_cache[v] = i - - local cv = cur_input_cache[v] - - if cv == nil then -- create new scope and run transform - local scope = create_node(subowner, false, false) - scopes[v] = scope :: Node - - local node = create_source_node(i) - - push_scope(scope) - - local ok, result = pcall(transform, v, function() - push_child_to_scope(node) - return node.cache - end) - - pop_scope() - - if not ok then - pop_scope() -- subowner scope - error(result, 0) - end - - input_nodes[v] = node - output_cache[v] = result - else -- update source - if cv ~= i then - input_nodes[v].cache = i - update_descendants(input_nodes[v]) - end - - cur_input_cache[v] = nil - end - end - - pop_scope() - - -- remove old values - for v in next, cur_input_cache do - destroy(scopes[v]) - - output_cache[v] = nil - input_nodes[v] = nil - scopes[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 - - local output_array = table.create(#scopes) - for _, v in next, output_cache do - table.insert(output_array, v) - end - check_primitives(output_array) - - return output_array - end - - local node = create_node(owner, function() - return update_children(input()) - end, false :: any) - - evaluate_node(node) - - return function() - push_child_to_scope(node) - return node.cache - end -end - -return function() return indexes, values end diff --git a/src/mount.luau b/src/mount.luau index b9d0ace..567d3d9 100644 --- a/src/mount.luau +++ b/src/mount.luau @@ -1,7 +1,5 @@ -if not game then script = require "test/relative-string" end - -local root = require(script.Parent.root) -local apply = require(script.Parent.apply) +local root = require "./root" +local apply = require "./apply" local function mount(component: () -> T, target: Instance?): () -> () return root(function() diff --git a/src/read.luau b/src/read.luau index d3a2fb7..3764315 100644 --- a/src/read.luau +++ b/src/read.luau @@ -1,5 +1,3 @@ -if not game then script = require "test/relative-string" end - local function read(value: T | () -> T): T return if type(value) == "function" then value() else value end diff --git a/src/root.luau b/src/root.luau index bc5904d..bc9c2fc 100644 --- a/src/root.luau +++ b/src/root.luau @@ -1,7 +1,4 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local create_node = graph.create_node local push_scope = graph.push_scope @@ -16,21 +13,20 @@ local function root(fn: (destroy: () -> ()) -> T...): (() -> (), T...) refs[node] = true -- prevent gc of root node local destroy = function() - if not refs[node] then throw "root already destroyed" end + if not refs[node] then error "root already destroyed" end refs[node] = nil destroy(node) end push_scope(node) - local function efn(err: string) return debug.traceback(err, 3) end - local result = { xpcall(fn, efn, destroy) } + local result = { xpcall(fn, debug.traceback, destroy) } pop_scope() if not result[1] then destroy() - throw(`error while running root():\n\n{result[2]}`) + error(`error while running root():\n\n{result[2]}`, 0) end return destroy, unpack(result :: any, 2) diff --git a/src/show.luau b/src/show.luau index 1cf60ca..27b9d45 100644 --- a/src/show.luau +++ b/src/show.luau @@ -1,18 +1,36 @@ -if not game then script = require "test/relative-string" end +local source = require "./source" +local derive = require "./derive" +local effect = require "./effect" +local untrack = require "./untrack" +local switch = require "./switch" -local switch = require(script.Parent.switch) +type Array = { T } +type Source = () -> T -local function show(source: () -> any, component: () -> T, fallback: (() -> T)?): () -> T? - local function truthy() - return not not source() - end +local function show( + input: Source, + component: (Source, Source) -> (Obj, ...number), + fallback: ((Source) -> (Obj, ...number))? +): Source> + local filtered_input = source() - return switch(truthy) { - [true] = component, - [false] = fallback, + effect(function() + local v = input() + if v then + filtered_input(v) + end + end) + + local input_is_truthy = derive(function() + return not not input() + end) + + return switch(input_is_truthy) { + [true] = function(present) + return component(filtered_input, present) + end, + [false] = fallback } end -return show :: - ((source: () -> any, component: () -> T) -> () -> T?) & - ((source: () -> any, component: () -> T, fallback: () -> U) -> () -> (T | U)?) +return show diff --git a/src/source.luau b/src/source.luau index e326815..1da515a 100644 --- a/src/source.luau +++ b/src/source.luau @@ -1,9 +1,7 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local create_source_node = graph.create_source_node -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local update_descendants = graph.update_descendants export type Source = (() -> T) & ((value: T) -> T) @@ -11,9 +9,9 @@ export type Source = (() -> T) & ((value: T) -> T) local function source(initial_value: T): Source local node = create_source_node(initial_value) - return function(...): T + local function update_source(...): T if select("#", ...) == 0 then -- no args were given - push_child_to_scope(node) + push_scope_as_child_of(node) return node.cache end @@ -26,6 +24,8 @@ local function source(initial_value: T): Source update_descendants(node) return v end + + return update_source end return source :: ((initial_value: T) -> Source) & (() -> Source) diff --git a/src/spring.luau b/src/spring.luau index a01ac1c..ef7decf 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -1,28 +1,4 @@ -if not game then script = require "test/relative-string" end -local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never - ---[[ - -Supported datatypes: -- number -- CFrame -- Color3 -- UDim -- UDim2 -- Vector2 -- Vector3 -- Rect - -Unsupported datatypes: -- bool -- Vector2int16 -- Vector3int16 -- EnumItem - -]] - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node type SourceNode = graph.SourceNode local create_node = graph.create_node @@ -30,74 +6,79 @@ local create_source_node = graph.create_source_node local assert_stable_scope = graph.assert_stable_scope local evaluate_node = graph.evaluate_node local update_descendants = graph.update_descendants -local push_child_to_scope = graph.push_child_to_scope +local push_scope_as_child_of = graph.push_scope_as_child_of local UPDATE_RATE = 120 -local TOLERANCE = 0.0001 - -type Vec3 = Vector3 - -local function Vec3(x: number?, y: number?, z: number?) - return Vector3.new(x, y, z) -end - -local ZERO = Vec3(0, 0, 0) +local TOLERANCE_FACTOR = 10_000 type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 -type SpringData = { +--[[ +Unsupported datatypes: +- bool +- Vector2int16 +- Vector3int16 +- EnumItem +]] + +type SpringState = { k: number, -- spring constant c: number, -- damping coeff - -- dimensions 1-3 - x0_123: Vec3, - x1_123: Vec3, - v_123: Vec3, - - -- dimensions 4-6 - x0_456: Vec3, - x1_456: Vec3, - v_456: Vec3, + x0_123: vector, x0_456: vector, -- initial position + x_123: vector, x_456: vector, -- current position + x1_123: vector, x1_456: vector, -- target position + v_123: vector, v_456: vector, -- current velocity source_value: T -- current value of spring input source } -type TypeToVec6 = (T) -> (Vec3, Vec3) -type Vec6ToType = (Vec3, Vec3) -> T +type SpringSettings = ({ + position: T?, + velocity: T?, + impulse: T? +}) -> () + +type TypeToVec6 = (T) -> (vector, vector) +type Vec6ToType = (vector, vector) -> T local type_to_vec6 = { number = function(v) - return Vec3(v, 0, 0), ZERO + return vector.create(v, 0, 0), vector.zero end :: TypeToVec6, CFrame = function(v) - return v.Position, Vec3(v:ToEulerAnglesXYZ()) + return v.Position, vector.create(v:ToEulerAnglesXYZ()) end :: TypeToVec6, Color3 = function(v) -- todo: hsv, oklab? - return Vec3(v.R, v.G, v.B), ZERO + return vector.create(v.R, v.G, v.B), vector.zero end :: TypeToVec6, UDim = function(v) - return Vec3(v.Scale, v.Offset, 0), ZERO + return vector.create(v.Scale, v.Offset, 0), vector.zero end :: TypeToVec6, UDim2 = function(v) - return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) + return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0) end :: TypeToVec6, Vector2 = function(v) - return Vec3(v.X, v.Y, 0), ZERO + return vector.create(v.X, v.Y, 0), vector.zero end :: TypeToVec6, Vector3 = function(v) - return v, ZERO + return v, vector.zero end :: TypeToVec6, Rect = function(v) - return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) - end :: TypeToVec6 + return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0) + end :: TypeToVec6, + + table = function(v) + return vector.create(v[1] or 0, v[2] or 0, v[3] or 0), vector.create(v[4] or 0, 0, 0) + end :: TypeToVec6<{ number }> } local vec6_to_type = { @@ -131,12 +112,16 @@ local vec6_to_type = { Rect = function(a, b) return Rect.new(a.X, a.Y, a.Z, b.X) - end :: Vec6ToType + end :: Vec6ToType, + + table = function(a, b) + return { a.X, a.Y, a.Z, b.X } + end :: Vec6ToType<{ number }> } local invalid_type = { __index = function(_, t: string) - throw(`cannot spring type {t}`) + error(`cannot spring type {t}`, 0) end } @@ -145,17 +130,15 @@ setmetatable(vec6_to_type, invalid_type) -- maps spring data to its corresponding output node -- lifetime of spring data is tied to output node -local springs: { [SpringData]: SourceNode } = {} -setmetatable(springs, { __mode = "v" }) +local springs: { [SpringState]: SourceNode } = {} +setmetatable(springs :: any, { __mode = "v" }) -local function spring(source: () -> T, period: number?, damping_ratio: number?): () -> T +local function spring(source: () -> T, period: number?, damping_ratio: number?): (() -> T, SpringSettings) local owner = assert_stable_scope() -- https://en.wikipedia.org/wiki/Damping - local w_n = 2*math.pi / (period or 1) local z = damping_ratio or 1 - local k = w_n^2 local c_c = 2*w_n local c = z * c_c @@ -163,20 +146,22 @@ local function spring(source: () -> T, period: number?, damping_ratio: number -- todo: is there a solution other than reducing step size? -- todo: this does not catch all solver exploding cases if c > UPDATE_RATE*2 then -- solver will explode if this is true - throw("spring damping too high, consider reducing damping or increasing period") + error("spring damping too high, consider reducing damping or increasing period", 0) end - local data: SpringData = { + local data: SpringState = { k = k, c = c, - x0_123 = ZERO, - x1_123 = ZERO, - v_123 = ZERO, + x0_123 = vector.zero, + x_123 = vector.zero, + x1_123 = vector.zero, + v_123 = vector.zero, - x0_456 = ZERO, - x1_456 = ZERO, - v_456 = ZERO, + x0_456 = vector.zero, + x_456 = vector.zero, + x1_456 = vector.zero, + v_456 = vector.zero, source_value = false :: any, } @@ -187,33 +172,57 @@ local function spring(source: () -> T, period: number?, damping_ratio: number local value = source() data.x1_123, data.x1_456 = type_to_vec6[typeof(value)](value) data.source_value = value - springs[data] = output -- todo: investigate why insertion is not O(1) at ~20k springs + springs[data] = output return value end local updater = create_node(owner, updater_effect, false :: any) - evaluate_node(updater) -- set initial position to goal - data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + data.x_123, data.x_456 = data.x1_123, data.x1_456 -- set output to goal output.cache = data.source_value + local config = function(p) + local x = p.position + local v = p.velocity + local dv = p.impulse + + if x then + local x_123, x_456 = type_to_vec6[typeof(x)](x) + data.x_123, data.x_456 = x_123, x_456 + data.x0_123, data.x0_456 = x_123, x_456 + end + + if v then + data.v_123, data.v_456 = type_to_vec6[typeof(v)](v) + end + + if dv then + local dv_123, dv_456 = type_to_vec6[typeof(dv)](dv) + data.v_123 += dv_123 + data.v_456 += dv_456 + end + + -- schedule spring + springs[data] = output + end :: SpringSettings + return function(...) if select("#", ...) == 0 then -- no args were given - push_child_to_scope(output) + push_scope_as_child_of(output) return output.cache end -- set current position to value local v = ... :: T - data.x0_123, data.x0_456 = type_to_vec6[typeof(v)](v) + data.x_123, data.x_456 = type_to_vec6[typeof(v)](v) -- reset velocity - data.v_123 = ZERO - data.v_456 = ZERO + data.v_123 = vector.zero + data.v_456 = vector.zero -- schedule spring springs[data] = output @@ -222,21 +231,32 @@ local function spring(source: () -> T, period: number?, damping_ratio: number output.cache = v return v - end + end, config +end + +-- calculates a float tolerance, based on the magnitude of the float +local function get_min_step(x: number): number + return x/TOLERANCE_FACTOR +end +local function get_min_vector_step(direction: vector): vector + return vector.create( + get_min_step(direction.x), + get_min_step(direction.y), + get_min_step(direction.z) + ) end local function step_springs(dt: number) - for data in next, springs do - local k, c, - x0_123, x1_123, u_123, - x0_456, x1_456, u_456 = - data.k, data.c, - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 + for s in springs do + local k = s.k + local c = s.c + local x_123, x_456 = s.x_123, s.x_456 + local x1_123, x1_456 = s.x1_123, s.x1_456 + local u_123, u_456 = s.v_123, s.v_456 -- calculate displacement from target - local dx_123 = x0_123 - x1_123 - local dx_456 = x0_456 - x1_456 + local dx_123 = x_123 - x1_123 + local dx_456 = x_456 - x1_456 -- calculate spring force local fs_123 = dx_123*-k @@ -246,53 +266,50 @@ local function step_springs(dt: number) local ff_123 = u_123*-c local ff_456 = u_456*-c - -- calculate acceleration step - local dv_123 = (fs_123 + ff_123)*dt - local dv_456 = (fs_456 + ff_456)*dt + -- calculate acceleration + local a_123 = (fs_123 + ff_123) + local a_456 = (fs_456 + ff_456) - -- apply acceleration step - local v_123 = u_123 + dv_123 - local v_456 = u_456 + dv_456 + -- step acceleration + local v_123 = u_123 + a_123*dt + local v_456 = u_456 + a_456*dt - -- calculate new position - local x_123 = x0_123 + v_123*dt - local x_456 = x0_456 + v_456*dt + -- step velocity + local y_123 = x_123 + v_123*dt + local y_456 = x_456 + v_456*dt - data.x0_123, data.x0_456 = x_123, x_456 - data.v_123, data.v_456 = v_123, v_456 + s.x_123, s.x_456 = y_123, y_456 + s.v_123, s.v_456 = v_123, v_456 end end -local remove_queue = {} - local function update_spring_sources() - for data, output in next, springs do - local x0_123, x1_123, v_123, - x0_456, x1_456, v_456 = - data.x0_123, data.x1_123, data.v_123, - data.x0_456, data.x1_456, data.v_456 - - local dx_123, dx_456 = - x0_123 - x1_123, - x0_456 - x1_456 + for data, output in springs do + local x0_123, x0_456 = data.x0_123, data.x0_456 + local x_123, x_456 = data.x_123, data.x_456 + local x1_123, x1_456 = data.x1_123, data.x1_456 + local v_123, v_456 = data.v_123, data.v_456 - -- todo: can this false positive? - if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then - -- close enough to target, unshedule spring and set value to target - table.insert(remove_queue, data) + local tol_123 = vector.abs(get_min_vector_step(x0_123 - x1_123)) + local tol_456 = vector.abs(get_min_vector_step(x0_456 - x1_456)) + + if + -- position is at goal (within tolerance) + vector.max(vector.abs(x_123 - x1_123), tol_123) == tol_123 + and vector.max(vector.abs(x_456 - x1_456), tol_456) == tol_456 + + -- velocity is at 0 (within tolerance) + and vector.max(vector.abs(v_123/10), tol_123) == tol_123 + and vector.max(vector.abs(v_456/10), tol_456) == tol_456 + then + springs[data] = nil output.cache = data.source_value else - output.cache = vec6_to_type[typeof(data.source_value)](x0_123, x0_456) + output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456) end update_descendants(output) end - - for _, data in next, remove_queue do - springs[data] = nil - end - - table.clear(remove_queue) end return function() diff --git a/src/switch.luau b/src/switch.luau index 99edd3c..0e74932 100644 --- a/src/switch.luau +++ b/src/switch.luau @@ -1,64 +1,113 @@ -if not game then script = require "test/relative-string" end - -local throw = require(script.Parent.throw) -local graph = require(script.Parent.graph) -type Node = graph.Node -type SourceNode = graph.SourceNode -local create_node = graph.create_node -local evaluate_node = graph.evaluate_node -local push_child_to_scope = graph.push_child_to_scope -local destroy = graph.destroy -local assert_stable_scope = graph.assert_stable_scope -local push_scope = graph.push_scope -local pop_scope = graph.pop_scope +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () +type Array = { T } type Map = { [K]: V } +type Source = () -> T +type Component = (Source) -> (T, ...number) -local function switch(source: () -> T): (map: Map U)?)>) -> () -> U? - local owner = assert_stable_scope() +local function switch_map( + input: Source, + map: Map> +): Source> + local scopes = {} :: Map (), + object: Obj, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }? + }> + local output = source(nil :: nil | Obj | Array) + local function update_output() + local objects = {} + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output( + if objects[2] then objects + elseif objects[1] then objects[1] + else nil + ) + end + + effect(function() + local key: K? = input() + + -- destroy (or queue destroy) all scopes not associated with the input key + for k, scope in scopes do + if k == key then continue end + + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[k] = nil + else + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() + scope.destroy() + scopes[k] = nil + update_output() + end) + end + end + end + + -- create new scope or abort destruction of existing scope if key exists + if key ~= nil then + local scope = scopes[key] + + if scope then + scope.present(true) + + if scope.timeout then + scope.timeout.cancel = true + scope.timeout = nil + end + else + local component = map[key] + + if component ~= nil then + if type(component) ~= "function" then + error("map must map a value to a function", 0) + end + + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(present) + delay = t + return object + end) + + present(true) + + scopes[key] = { + destroy = destroy, + object = object, + delay = delay or 0, + present = present, + timeout = nil + } + end + end + end + + update_output() + end) + + return output +end + +local function switch(input: Source): (map: Map>) -> Source> return function(map) - local last_scope: Node? - local last_component: (() -> U)? - - local function update(cached): U? - local component = map[source()] - if component == last_component then return cached end - last_component = component - - if last_scope then - destroy(last_scope :: Node) - last_scope = nil - end - - if component == nil then return nil end - - if type(component) ~= "function" then - throw "map must map a value to a function" - end - - local new_scope = create_node(owner, false, false) - last_scope = new_scope :: Node - - push_scope(new_scope) - - local ok, result = pcall(component) - - pop_scope() - - if not ok then error(result, 0) end - - return result - end - - local node = create_node(owner, update, nil) - - evaluate_node(node) - - return function() - push_child_to_scope(node) - return node.cache - end + return switch_map(input, map) end end diff --git a/src/throw.luau b/src/throw.luau deleted file mode 100644 index 954f3e2..0000000 --- a/src/throw.luau +++ /dev/null @@ -1,7 +0,0 @@ -if not game then script = require "test/relative-string" end - -local function VIDE_ASSERT(msg): any - error(msg, 0) -end - -return VIDE_ASSERT diff --git a/src/timeout.luau b/src/timeout.luau new file mode 100644 index 0000000..16319e3 --- /dev/null +++ b/src/timeout.luau @@ -0,0 +1,27 @@ +local queue = {} :: { + { t: number, fn: () -> (), cancel: boolean } +} + +local function timeout(t: number, fn: () -> ()) + local handle = { t = t, fn = fn, cancel = false } + table.insert(queue, handle) + return handle +end + +local function update_timeouts(dt: number) + for i = #queue, 1, -1 do + local handle = queue[i] + handle.t -= dt + + if handle.cancel or handle.t <= 0 then + queue[i] = queue[#queue] + queue[#queue] = nil + + if not handle.cancel then + handle.fn() + end + end + end +end + +return function() return timeout, update_timeouts end diff --git a/src/untrack.luau b/src/untrack.luau index 86cdb7b..6e15578 100644 --- a/src/untrack.luau +++ b/src/untrack.luau @@ -1,6 +1,4 @@ -if not game then script = require "test/relative-string" end - -local graph = require(script.Parent.graph) +local graph = require "./graph" type Node = graph.Node local get_scope = graph.get_scope @@ -12,16 +10,16 @@ local function untrack(source: () -> T): T local effect = scope.effect scope.effect = false - local ok, result = pcall(source) + local ok, result = xpcall(source, debug.traceback) scope.effect = effect :: () -> () if not ok then error(result, 0) end - return result + return result :: T else return source() end end -return untrack +return untrack :: ( (fn: () -> T) -> T ) & ( (fn: () -> ()) -> () ) diff --git a/src/values.luau b/src/values.luau new file mode 100644 index 0000000..33bc5e4 --- /dev/null +++ b/src/values.luau @@ -0,0 +1,131 @@ +local flags = require "./flags" +local branch = require "./branch" +local source = require "./source" +local effect = require "./effect" +local timeout = require "./timeout" () + +type Array = { T } +type Map = { [K]: V } +type Source = () -> T + +local function values( + input: Source>, + component: (V, Source, Source) -> (Obj, ...number) +): Source> + local update_count = 0 + local scopes = {} :: Map (), + object: Obj, + index: K?, + index_source: (K?) -> K, + count: number, + delay: number, + present: (boolean?) -> boolean, + timeout: { cancel: boolean }?, + }> + + local output = source({} :: Array) + local function update_output() + local objects = table.create(4) + + for _, scope in scopes do + table.insert(objects, scope.object) + end + + output(objects) + end + + effect(function() + local data = input() + + local count = update_count + update_count += 1 + + local children_need_update = false -- set to true if a scope is created or destroyed + + if flags.strict then -- check for duplicate values + local map = {} + for _, v in data do + if map[v] then + error("table source passed to `values()` contains duplicate values", 0) + end + map[v] = true + end + end + + -- create or update scopes + for i, v in data do + local scope = scopes[v] + + if scope == nil then -- create new scope and create component + local index_source = source(i) + local present = source(false) + + local delay = nil :: number? + local destroy, object = branch(function() + local object, t = component(v, index_source, present) + delay = t + return object + end) + + present(true) + + children_need_update = true + + scopes[v] = { + destroy = destroy, + object = object, + index = i, + index_source = index_source, + count = count, + delay = delay or 0, + present = present, + timeout = nil, + } + else -- update scope + scope.count = count + + if scope.index ~= i then + if scope.timeout then -- value is in input table again; cancel destruction + scope.timeout.cancel = true + scope.timeout = nil + scope.present(true) + end + + scope.index = i + scope.index_source(i) + end + end + end + + -- destroy scopes + for v, scope in scopes do + if scope.count < count then -- if count is not latest then value is no longer in the input table + scope.present(false) + + if scope.delay == 0 then + scope.destroy() + scopes[v] = nil + children_need_update = true + else + scope.index = nil -- set to nil for the `scope.index ~= i` check + if scope.timeout == nil then + scope.timeout = timeout(scope.delay, function() -- todo: possible redundant updates + scope.destroy() + scopes[v] = nil + update_output() + end) + end + end + end + end + + if children_need_update then + update_output() + end + end) + + return output +end + +return values diff --git a/test/benchmark.luau b/test/benchmarks.luau similarity index 81% rename from test/benchmark.luau rename to test/benchmarks.luau index 05fe3ef..cbb55f9 100644 --- a/test/benchmark.luau +++ b/test/benchmarks.luau @@ -1,7 +1,7 @@ -local testkit = require("test/testkit") +local testkit = require("./testkit") local BENCH, START = testkit.benchmark() -local vide = require "src/init" +local vide = require "../../vide" local source = vide.source local derive = vide.derive local effect = vide.effect @@ -27,7 +27,7 @@ local function ROOT_BENCH(name: string, fn: () -> ()) end)() end -local N = 2^18 -- 262144 +local N = 2^20 TITLE "sources" @@ -200,7 +200,7 @@ end) TITLE "property apply" ROOT_BENCH("apply 0 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -209,7 +209,7 @@ ROOT_BENCH("apply 0 properties", function() end) ROOT_BENCH("apply 8 properties", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} for i = 1, START(N) do @@ -227,7 +227,7 @@ ROOT_BENCH("apply 8 properties", function() end) ROOT_BENCH("bind property", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -242,7 +242,7 @@ ROOT_BENCH("bind property", function() end) ROOT_BENCH("update binding", function() - local apply = require "src/apply" + local apply = require "../src/apply" local instance = create("Frame") {} local src = source(1) @@ -504,85 +504,44 @@ ROOT_BENCH(`get context (depth={depth})`, function() end) end) -N *= 1024 +TITLE "spring()" -TITLE "aggregate" +ROOT_BENCH("spring update", function() + local root, source, spring = vide.root, vide.source, vide.spring -do - -- the purpose of the two following benchmarks is to measure the overhead of - -- aggregate construction - ROOT_BENCH("set explicit mock vector2", function() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 + local src = source(0) - local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) - } - - for i = 1, START(N) do - apply(label, { - AnchorPoint = Vector2.new(i, i) - }) + root(function() + for i = 1, N do + spring(src) end + + START(N) + + src(1) + + return nil end) +end) - ROOT_BENCH("set aggregate mock vector2", function() - local apply = require "src/apply" - local Vector2 = require "test/mock".Vector2 +ROOT_BENCH("spring step", function() + local root, source, spring = vide.root, vide.source, vide.spring - local label = create "TextLabel" { - AnchorPoint = Vector2.new(1, 1) - } + local src = source(0) - for i = 1, START(N) do - apply(label, { - AnchorPoint = { i, i } - }) + root(function() + for i = 1, N do + spring(src) end + + src(1) + + START(N) + + vide.step(1/60) + + return nil end) -end - --- innacurate due to no Vector3 in vanilla Luau --- mock vector is 200x slower than native vector - --- ROOT_BENCH("spring update", function() --- local root, source, spring = vide.root, vide.source, vide.spring - --- local src = source(0) - --- root(function() --- for i = 1, N do --- spring(src) --- end - --- START(N) - --- src(1) - --- return nil --- end) --- end) - --- N /= 1024 - --- ROOT_BENCH("spring step", function() --- local root, source, spring = vide.root, vide.source, vide.spring - --- local src = source(0) - --- root(function() --- for i = 1, N do --- spring(src) --- end - --- src(1) - --- START(N) - --- vide.step(1/60) - --- return nil --- end) --- end) +end) return nil diff --git a/test/create-type-test.luau b/test/create-type-test.luau new file mode 100644 index 0000000..b30ffd5 --- /dev/null +++ b/test/create-type-test.luau @@ -0,0 +1,27 @@ +local vide = require "../src/" + +local count = vide.source(0) + +vide.create("TextButton") { + BackgroundTransparency = 1, + AnchorPoint = "bad value", -- should error + InvalidProperty = true, -- should error + + Text = function() + return "count: " .. count() + end, + + Size = function() -- should error + return "bad value" + end, + + MouseEnter = function(x, y) + + end, + + Activated = "bad value", -- should error + + vide.create "TextLabel" {}, + + function() end, +} diff --git a/test/mock.luau b/test/mock.luau index 1dfb9bc..a1f22a6 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -86,6 +86,9 @@ local Instance = {} :: any do local proxies = {} :: { [Data]: userdata? } setmetatable(proxies :: any, { __mode = "v" }) + -- allocate variables for the metamethods as __index and get_proxy cross refrences each other + local __index, __newindex + local function get_data(userdata: userdata): Data local function f(userdata: userdata): ProxyMT return getmetatable(userdata :: any) @@ -94,6 +97,19 @@ local Instance = {} :: any do return f(userdata).data 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 + local function is_instance(value: unknown): boolean local mt = getmetatable(value :: any) return mt and mt.data and mt.data.type == "Instance" @@ -101,16 +117,16 @@ local Instance = {} :: any do local methods = {} - local function __index(userdata: userdata, property: string): () + __index = function(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 == "Parent" then (data.parent and get_proxy(data.parent)) elseif property == "Destroying" then data.destroying else data.properties[property] end - local function __newindex(userdata: userdata, property: string, value: unknown) + __newindex = function(userdata: userdata, property: string, value: unknown) local data = get_data(userdata) if property == "Name" then if type(value) ~= "string" then error("name must be a string", 2) end @@ -135,19 +151,6 @@ local Instance = {} :: any do 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", @@ -257,40 +260,6 @@ local Vector2 = { __type = "Vector2" } :: any do end end -local Vector3 = { __type = "Vector3" } :: any do - local function new(x, y, z) - return setmetatable({ X = x, Y = y, Z = z }, Vector3) - end - - function Vector3.new(x, y, z) - return new(x or 0, y or 0, z or 0) - end - - function Vector3.__add(a, b) - return new(a.X + b.X, a.Y + b.Y, a.Z + b.Z) - end - - function Vector3.__sub(a, b) - return new(a.X - b.X, a.Y - b.Y, a.Z - b.Z) - end - - function Vector3.__mul(a, b) - return new(a.X * b, a.Y * b, a.Z * b) - end - - function Vector3.__unm(v) - return new(-v.X, -v.Y, -v.Z) - end - - function Vector3.__eq(a, b) - return a.X == b.X and a.Y == b.Y - end - - function Vector3.__index(v) - return (v.X^2 + v.Y^2 + v.Z^2)^0.5 - end -end - local UDim2 = { __type = "UDim2" } :: any do function UDim2.new(sx, ox, sy, oy) return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2)) @@ -330,7 +299,6 @@ return { Instance = Instance :: typeof(Instance), Color3 = Color3 :: typeof(Color3), Vector2 = Vector2 :: typeof(Vector2), - Vector3 = Vector3 :: typeof(Vector3), UDim2 = UDim2 :: typeof(UDim2), Enum = Enum :: typeof(Enum), typeof = typeof :: typeof(typeof) diff --git a/test/relative-string.luau b/test/relative-string.luau deleted file mode 100644 index 232215c..0000000 --- a/test/relative-string.luau +++ /dev/null @@ -1,9 +0,0 @@ -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/test/spring-test.luau b/test/spring-test.luau index 2e3bc9b..1385eda 100644 --- a/test/spring-test.luau +++ b/test/spring-test.luau @@ -1,25 +1,34 @@ -local vide = require "src/init" -local testkit = require("test/testkit") +local vide = require "../../vide" -local program_time = os.clock() +local function system(): (number) -> number + local MAX = 40 + local MIN = 10 -local function step(): number - local FPS = 60 - local DT = 1/FPS + local _, input, output = vide.root(function() + local input = vide.source(MAX) + local output = vide.spring(input, 1, .3) + return input, output + end) - repeat until os.clock() - program_time >= DT - program_time += DT - return DT + local T = 10 + local t = 0 + return function(dt) + t += dt + if t >= T then + t -= T + input(input() == MAX and MIN or MAX) + end + + vide.step(dt) + + return output() + end end -local function main() - local TERMINAL_HEIGHT = 73 --* REDUCE IF BAR DOES NOT FIT IN TERMINAL - local MIN_ALPHA = 0.3 - local MAX_ALPHA = 0.7 +-------------------------------------------------------------------------------- - local MIN = TERMINAL_HEIGHT * MIN_ALPHA - local MAX = TERMINAL_HEIGHT * MAX_ALPHA - local OFFSET = TERMINAL_HEIGHT - MAX +local function redraw_block(h: number) + local OFFSET = 70 local BLOCK = "█" @@ -35,37 +44,35 @@ local function main() else "▁" end - local source = vide.source - local spring = vide.spring - local effect = vide.effect - - local value = source(MAX) - local sprung = spring(value, 1, 0.3) - - effect(function() - local v = sprung() - local fv = math.floor(v) - local reset = "\27[H\27[2J" -- ANSI clear terminal - local offset = string.rep("\n", MAX - fv + OFFSET) - local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) - print(reset .. offset .. bar .. "\n" .. v) - end) - - local T = 3 - local elapsed = T/1.2 - repeat local dt = step() - vide.step(dt) - - elapsed += dt - while elapsed >= T do - elapsed -= T - value(value() == MAX and MIN or MAX) - end - - until false + local h_f = math.floor(h) + local reset = "\27[H\27[2J" -- ANSI clear terminal + local offset = string.rep("\n", OFFSET - h_f) + local bar = remainder_to_block(h - h_f) .. "\n" .. string.rep(BLOCK .. "\n", h_f) + --print(reset .. offset .. bar .. "\n" .. string.format("%.1f", h)) + print(reset .. offset .. bar .. "\n" .. h) end -vide.root(main) - +local program_time = os.clock() + +local function step(): number + local FPS = 30 + local DT = 1/FPS + + repeat until os.clock() - program_time >= DT + program_time += DT + return DT +end + +local function loop() + local callback = system() + + while true do + local dt = step() + local x = callback(dt) + redraw_block(x) + end +end + +loop() diff --git a/test/stacktrace-test.luau b/test/stacktrace-test.luau new file mode 100644 index 0000000..e24c314 --- /dev/null +++ b/test/stacktrace-test.luau @@ -0,0 +1,110 @@ +local vide = require "../" + +do + print "=============================================================" + + local a = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + b(vide.untrack(b) + 1) + end) + + vide.effect(function() + b() + c(vide.untrack(c) + 1) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end + +do + print "=============================================================" + + local a = vide.source(1) + local b = vide.source(1) + local c = vide.source(1) + + local cause_error = false + + local function try_error() + if cause_error then error("uh oh") end + end + + vide.root(function() + vide.effect(function() + a() + vide.untrack(function() -- todo: this trace appearing twice + b(b() + 1) + return nil + end) + end) + + vide.effect(function() + b() + vide.batch(function() + c(vide.untrack(c) + 1) + end) + end) + + + vide.effect(function() + c() + try_error() + end) + end) + + cause_error = true + + local ok, result = pcall(function() a(2) end) + print(result) + + print "=============================================================" +end diff --git a/test/tests.luau b/test/tests.luau index 3fbdbf4..af6a7f4 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,12 +1,32 @@ -local testkit = require("test/testkit") +local testkit = require "./testkit" local TEST, CASE, CHECK, FINISH = testkit.test() -local mock = require "test/mock" +local mock = require "./mock" local Instance, Signal = mock.Instance, mock.Signal local Vector2, UDim2 = mock.Vector2, mock.UDim2 -local vide = require "src/init" -local graph = require "src/graph" +local vide = require "../../vide" + local root = vide.root + local mount = vide.mount + local create = vide.create + local source = vide.source + local effect = vide.effect + local derive = vide.derive + local switch = vide.switch + local show = vide.show + local indexes = vide.indexes + local values = vide.values + local cleanup = vide.cleanup + local untrack = vide.untrack + local read = vide.read + local batch = vide.batch + local context = vide.context + local spring = vide.spring + local action = vide.action + local changed = vide.changed + local apply = vide.apply + local step = vide.step +local graph = require "../../vide/src/graph" type Node = graph.Node type Map = { [K] : V } @@ -35,7 +55,7 @@ vide.strict = false TEST("graph", function() local create_node = graph.create_node - local push_child_to_scope = graph.push_child_to_scope + local push_scope_as_child_of = graph.push_scope_as_child_of local update_descendants = graph.update_descendants local push_child = graph.push_child local get_scope = graph.get_scope @@ -65,8 +85,8 @@ TEST("graph", function() push_scope(c) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) pop_scope() @@ -83,8 +103,8 @@ TEST("graph", function() local count = 0 local function effect(x) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) count += 1 return not x end @@ -115,9 +135,9 @@ TEST("graph", function() function c.effect(x) c_cnt += 1; return not x end function d.effect(x) d_cnt += 1; return not x end - push_scope(b); push_child_to_scope(a); pop_scope() - push_scope(c); push_child_to_scope(a); pop_scope() - push_scope(d); push_child_to_scope(b); push_child_to_scope(c); pop_scope() + push_scope(b); push_scope_as_child_of(a); pop_scope() + push_scope(c); push_scope_as_child_of(a); pop_scope() + push_scope(d); push_scope_as_child_of(b); push_scope_as_child_of(c); pop_scope() update_descendants(a) @@ -131,8 +151,8 @@ TEST("graph", function() local a, b, c = node(root), node(root), node(root) function c.effect(x) - push_child_to_scope(a) - push_child_to_scope(b) + push_scope_as_child_of(a) + push_scope_as_child_of(b) return not x end @@ -171,10 +191,10 @@ TEST("graph", function() do push_scope(root) clean "root" items_updated = node(root) - push_child_to_scope(items_updated) -- should not + push_scope_as_child_of(items_updated) -- should not do push_scope(items_updated) - push_child_to_scope(items) + push_scope_as_child_of(items) do push_scope(root) do push_scope(scope1) @@ -183,7 +203,7 @@ TEST("graph", function() do push_scope(bind1) clean "bind1" - push_child_to_scope(selected) + push_scope_as_child_of(selected) pop_scope() end pop_scope() end @@ -192,7 +212,7 @@ TEST("graph", function() bind2 = node(scope2) do push_scope(bind2) clean "bind2" - push_child_to_scope(selected) + push_scope_as_child_of(selected) pop_scope() end pop_scope() end pop_scope() end @@ -300,11 +320,6 @@ TEST("graph", function() end) TEST("mount()", function() - local mount = vide.mount - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup - local screen = create "ScreenGui" {} local text = source "foo" @@ -334,9 +349,6 @@ TEST("mount()", function() end) TEST("root()", function() - local root = vide.root - local cleanup = vide.cleanup - local count = 0 root(function(destroy) @@ -348,9 +360,6 @@ TEST("root()", function() end) TEST("source()", wrap_root(function() - local source = vide.source - local effect = vide.effect - do CASE "create source" local src = source(1) CHECK(src() == 1) @@ -415,11 +424,6 @@ TEST("source()", wrap_root(function() end)) TEST("derive()", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "derive new value on source change" local a = source(1) local b = source(2) @@ -507,7 +511,7 @@ TEST("derive()", wrap_root(function() local count = 0 local a = source(0) - local destroy = vide.mount(function() + local destroy = mount(function() local _b = derive(function() cleanup(function() count += 1 @@ -580,10 +584,6 @@ TEST("derive()", wrap_root(function() end)) TEST("effect()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local derive = vide.derive - do CASE "rerun on source change" local a = source(1) local b = source(1) @@ -636,15 +636,10 @@ TEST("effect()", wrap_root(function() end)) TEST("cleanup()", wrap_root(function() - local root = vide.root - local source = vide.source - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "root cleanup" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() cleanup(function() count += 1 end) @@ -717,12 +712,21 @@ TEST("cleanup()", wrap_root(function() end)) TEST("create()", wrap_root(function() - local create = vide.create - local source = vide.source - local cleanup = vide.cleanup + do CASE "create(\"ClassName\", props) syntax" + local frame = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + CHECK(frame.BackgroundTransparency == 0.5) + CHECK(frame.Name == "Foo") + end + + do CASE "create(Instance, props) syntax" + local frame0 = create("Frame", { BackgroundTransparency = 0.5, Name = "Foo" }) + local frame = create(frame0, { BackgroundTransparency = 1 }) + CHECK(frame.BackgroundTransparency == 1) + CHECK(frame.Name == "Foo") + end do CASE "apply default properties" - local defaults = require("src/defaults") + 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) @@ -746,33 +750,42 @@ TEST("create()", wrap_root(function() CHECK(text.Text == "test") end - do CASE "aggregate construction" - local template = create "TextLabel" { - AnchorPoint = Vector2.new(), - Position = UDim2.new() - } - - local text = create(template) { - AnchorPoint = { 1, 2 }, - Position = { 3, 4 } - } - - CHECK(text.AnchorPoint == Vector2.new(1, 2)) - CHECK(text.Position == UDim2.new(3, 4)) + do CASE "set nested parent" + local frame = create "Frame" {} + local text = create "TextLavel" { { Parent = frame } } + CHECK(frame:GetChildren()[1] == text) + CHECK(text.Parent == frame) end - do CASE "nested precedence" + do CASE "nested deferred" local text = create "TextLabel" { { + { Text = "2" }, Text = "1", - - { Text = "2" } } } CHECK(text.Text == "2") end + do CASE "nested not deferred" + vide.defer_nested_properties = false + + local t = {} + + create "TextLabel" { + { + { function() table.insert(t, 1) end } :: any, + function() table.insert(t, 2) end, + } + } + + CHECK(t[1] == 1) + CHECK(t[2] == 2) + + vide.defer_nested_properties = true + end + do CASE "independent" local frame = create "Frame" CHECK(frame {} ~= frame {}) @@ -804,6 +817,20 @@ TEST("create()", wrap_root(function() CHECK(frame:FindFirstChild "G") end + do CASE "set false as child" + create "Frame" { + false + } + + create "Frame" { + function() return false end + } + + create "Frame" { + function() return { false } end + } + end + do CASE "binding properties to source" local name = source("Hi") local text = source("Bye") @@ -826,7 +853,7 @@ TEST("create()", wrap_root(function() do CASE "binding destroy" local count = 0 - local destroy = vide.mount(function() + local destroy = mount(function() local src = source(0) return create "TextLabel" { @@ -892,7 +919,7 @@ TEST("create()", wrap_root(function() end do CASE "parent bound to source" - local _, wref, destroy = vide.root(function(destroy) + local _, wref, destroy = root(function(destroy) local frame = create "Frame" { Name = "Parent" } local parent = source(frame :: Frame?) @@ -919,7 +946,7 @@ TEST("create()", wrap_root(function() end do CASE "recursive create" - local set_test_to_true = vide.action(function(self) (self :: any).test = true end) + local set_test_to_true = action(function(self) (self :: any).test = true end) local f2 @@ -981,6 +1008,48 @@ TEST("create()", wrap_root(function() CHECK(not obj:FindFirstChild("e")) end + do CASE "nested children source effect" + local a = create "Frame" { Name = "a" } :: Instance + local b = create "Frame" { Name = "b" } :: Instance + local c = create "Frame" { Name = "c" } :: Instance + + local nested_children = source { b, c } + local children = source { a :: Instance | () -> { Instance }, nested_children } + + local parent = create "Frame" { + Name = "parent", + children + } + + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children {} + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + nested_children { b, c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(not parent:FindFirstChild "c") + children { a :: Instance | () -> { Instance }, nested_children } + CHECK(parent:FindFirstChild "a") + CHECK(parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + nested_children { c } + CHECK(parent:FindFirstChild "a") + CHECK(not parent:FindFirstChild "b") + CHECK(parent:FindFirstChild "c") + end + do CASE "garbage collection test" local wref @@ -1009,29 +1078,148 @@ TEST("create()", wrap_root(function() end)) TEST("show()", wrap_root(function() - local untrack = vide.untrack - local cleanup = vide.cleanup - local source = vide.source - local effect = vide.effect - local show = vide.show - local root = vide.root + do CASE "show component" + local input = source(true) + local function one() return 1 end - do CASE "main" - -- uses switch() internally, more extensive testing of scoping not needed - local value = source("truey" :: unknown) + local output = show(input, one) + + CHECK(output() == 1) + input(false) + CHECK(output() == nil) + end + + do CASE "fallback component" + local input = source(true) local function one() return 1 end local function two() return 2 end - local output = show(value, one, two) + local output = show(input, one, two) CHECK(output() == 1) - value(nil) + input(false) CHECK(output() == 2) end - do CASE "alt" - local visible = vide.source(true) - local count = vide.source(0) + do CASE "updating truth to truthy does not rerun" + local input = source(1) + local count = 0 + + local function component() + count += 1 + return 1 + end + + local output = show(input, component) + + CHECK(count == 1) + CHECK(output() == 1) + input(2) + CHECK(count == 1) + end + + do CASE "updating source passed to component" + local input = source(1 :: number?) + local count = 0 + + show(input :: () -> number?, function(value: () -> number) + effect(function() + local v = value() + + count += 1 + + CHECK(v == count) + if v ~= count then error(count) end + end) + + return true + end) + + input(2) + CHECK(count == 2) + input(3) + CHECK(count == 3) + input(nil) + CHECK(count == 3) + end + + do CASE "special strict case" + type Weapon = { + id: string, + enchant: string? + } + + vide.strict = true + + local count = 0 + local branch = 0 + + local weapon = source(nil :: Weapon?) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + show(weapon, function(weapon: () -> Weapon) + local enchant = function() return weapon().enchant end + + show(enchant, function(enchant: () -> string) + effect(function() + local e = enchant() + count += 1 + CHECK(e ~= nil) + if branch == 1 then + CHECK(e == "fire") + elseif branch == 2 then + CHECK(e == "poison") + end + end) + + return {} + end) + + return {} + end) + + effect(function() + weapon() + end) + + effect(function() + weapon() + end) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 2) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 4) + + weapon { id = "1", enchant = nil } + CHECK(count == 4) + + branch = 1 + weapon { id = "1", enchant = "fire" } + CHECK(count == 6) + + weapon(nil) + + branch = 2 + weapon { id = "1", enchant = "poison" } + CHECK(count == 8) + + vide.strict = false + end + + do CASE "alt" -- todo: move test + local visible = source(true) + local count = source(0) local outer = 0 local inner = 0 @@ -1052,7 +1240,6 @@ TEST("show()", wrap_root(function() destroyed += 1 end) end) - return nil end) end) end) @@ -1076,14 +1263,161 @@ TEST("show()", wrap_root(function() CHECK(inner == 4) CHECK(destroyed == 3) end + + do CASE "delay (destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == nil) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(cleaned) + end + + do CASE "delay (reactivate before destruction)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end) + + CHECK(output() == nil) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + input(false) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + step(0.5) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == false) + CHECK(not cleaned) + + input(true) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + + step(0.5 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + end + + do CASE "delay (with fallback)" + local input = source(false) + + local obj = {} + local value_upval + local present_upval + local cleaned = false + + local obj_fallback = {} + local present_fallback_upval + local cleaned_fallback = false + + local output = show(input, function(value, present) + value_upval = value + present_upval = present + cleanup(function() cleaned = true end) + CHECK(present() == false) + return obj, 1 + end, function(present) + present_fallback_upval = present + cleanup(function() cleaned_fallback = true end) + CHECK(present() == false) + return obj_fallback, 1 + end) + + CHECK(output() == obj_fallback) + CHECK(value_upval == nil) + CHECK(present_upval == nil) + CHECK(not cleaned) + CHECK(present_fallback_upval() == true) + CHECK(not cleaned_fallback) + + input(true) + + CHECK(type(output() == "table") and table.find(output(), obj) and table.find(output(), obj_fallback)) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(not cleaned_fallback) + + step(1 + 0.01) + + CHECK(output() == obj) + CHECK(value_upval() == true) + CHECK(present_upval() == true) + CHECK(not cleaned) + CHECK(present_fallback_upval() == false) + CHECK(cleaned_fallback) + end end)) TEST("switch()", wrap_root(function() - local source = vide.source - local switch = vide.switch - local effect = vide.effect - local cleanup = vide.cleanup - do CASE "update on source change" local input = source(true) @@ -1111,28 +1445,27 @@ TEST("switch()", wrap_root(function() CHECK(output() == nil) end - do CASE "same component different map" - local input = source(0) + -- do CASE "same component different map" + -- local input = source(0) - local function component() - return {} - end + -- local function component() + -- return {} + -- end - local output = switch(input) { - [1] = component, - [2] = component - } + -- local output = switch(input) { + -- [1] = component, + -- [2] = component + -- } - CHECK(output() == nil) + -- CHECK(output() == nil) - input(1) - local instance = output() - CHECK(instance) + -- input(1) + -- local instance = output() + -- CHECK(instance) - input(2) - CHECK(output() == instance) - - end + -- input(2) + -- CHECK(output() == instance) + -- end do CASE "scoped switch" local input = source(true) @@ -1169,7 +1502,7 @@ TEST("switch()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source(1) local n0 = scopes.n @@ -1202,15 +1535,13 @@ TEST("switch()", wrap_root(function() vide.strict = false end + + do CASE "delay" + -- probably unneeded because show() uses switch() internally + end end)) TEST("indexes()", wrap_root(function() - local create = vide.create - local source = vide.source - local effect = vide.effect - local indexes = vide.indexes - local cleanup = vide.cleanup - do CASE "use source" local input = source { 1, 2, 3 } @@ -1228,7 +1559,7 @@ TEST("indexes()", wrap_root(function() local count = table.create(3, 0) - local _, output = vide.root(function() + local _, output = root(function() local output = indexes(input, function(v, i) count[i] += 1 return v @@ -1294,9 +1625,12 @@ TEST("indexes()", wrap_root(function() do -- check that `input` allows gc of `output` local input = source {} - local output = indexes(input, function(v, i) - return v, i + local destroy, output = root(function() + return indexes(input, function(v, i) + return v, i + end) end) + destroy() local wref = weak { output } @@ -1328,7 +1662,7 @@ TEST("indexes()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -1400,14 +1734,84 @@ TEST("indexes()", wrap_root(function() vide.strict = false end + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = indexes(input, function(v, i, present) + cleanup(function() + cleaned_counts[i] = (cleaned_counts[i] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.index] = { value = object.value, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1, 2 } + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].value() == 2) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(not mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].value() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].value() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(mapped()[1].value() == 3) + CHECK(mapped()[1].present()) + + CHECK(not mapped()[2]) + + CHECK(cleaned_counts[1] == nil) + CHECK(cleaned_counts[2] == 1) + end + end end)) TEST("values()", wrap_root(function() - local create = vide.create - local source = vide.source - local values = vide.values - local cleanup = vide.cleanup - do CASE "use source" local input = source { 1, 2, 3 } @@ -1507,7 +1911,7 @@ TEST("values()", wrap_root(function() end do CASE "reactive stack resets after error" - local scopes = require "src/graph".scopes + local scopes = require "../src/graph".scopes local input = source { 1 } @@ -1526,14 +1930,103 @@ TEST("values()", wrap_root(function() CHECK(n0 == n1) end + + do CASE "delay" + local input = source {} + + local cleaned_counts = {} :: Map + + local output = values(input, function(v, i, present) + cleanup(function() + cleaned_counts[v] = (cleaned_counts[v] or 0) + 1 + end) + return { value = v, index = i, present = present }, 1 + end) + + local function mapped() + local map = {} + local objects = output() + if objects then + for _, object in objects do + map[object.value] = { index = object.index, present = object.present } + end + end + return map + end + + ------------------------------------------------------------------------ + + do + CHECK(mapped()[1] == nil) + end + + input { 1 } + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + end + + input { 2 } + step(0.5) + + do + CHECK(mapped()[1].index() == 1) + CHECK(not mapped()[1].present()) + + CHECK(mapped()[2].index() == 1) + CHECK(mapped()[2].present()) + end + + input { 1, 2 } + step(0.5 + 0.01) + + do + CHECK(mapped()[1].index() == 1) + CHECK(mapped()[1].present()) + + CHECK(mapped()[2].index() == 2) + CHECK(mapped()[2].present()) + end + + input { 3 } + step(1 + 0.01) + + do + CHECK(not mapped()[1]) + CHECK(not mapped()[2]) + + CHECK(mapped()[3].index() == 1) + CHECK(mapped()[3].present()) + + CHECK(cleaned_counts[1] == 1) + CHECK(cleaned_counts[2] == 1) + CHECK(cleaned_counts[3] == nil) + end + end + + do CASE "delayed destruction deferred" + local input = source {} + local output = values(input, function() + return {}, 1 + end) + + local count = 0 + effect(function() output(); count += 1 end) + + input { 1, 2, 3 } + CHECK(count == 2) + + input {} + CHECK(count == 2) + + step(1 + 0.01) + --CHECK(count == 3) + CHECK(count == 5) + end end)) TEST("spring()", wrap_root(function() - local create = vide.create - local source = vide.source - local spring = vide.spring - local effect = vide.effect - do CASE "update source (on next step)" local value = source(10) local sprung = spring(value, 1, 1) @@ -1541,7 +2034,7 @@ TEST("spring()", wrap_root(function() CHECK(sprung() == 10) value(20) CHECK(sprung() == 10) - vide.step(1/60) + step(1/60) CHECK(sprung() ~= 10) CHECK(sprung() > 10) end @@ -1607,9 +2100,9 @@ TEST("spring()", wrap_root(function() local output = spring(input) input(1) - vide.step(0.05) + step(0.05) CHECK(output() ~= input()) -- check spring is moving - vide.step(10) -- spring finished, should be internally removed from queue + step(10) -- spring finished, should be internally removed from queue CHECK(output() == input()) -- check spring is at target local count = -1 @@ -1618,25 +2111,43 @@ TEST("spring()", wrap_root(function() count += 1 end) - vide.step(1) -- attempt to cause another spring update + step(1) -- attempt to cause another spring update CHECK(count == 0) -- 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 + step(0) -- process spring queue CHECK(count == 1) -- check spring was rescheduled correctly end + + do CASE "spring control" + local input = source(1) + local output, control = spring(input) + + local value = input() + local count = 0 + effect(function() + value = output() + count += 1 + end) + + CHECK(count == 1) + CHECK(value == 1) + + control { impulse = 1 } + + CHECK(count == 1) + CHECK(value == 1) + + step(1/120 + 0.001) + + CHECK(count == 2) + CHECK(value > 1) + end end)) TEST("untrack()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local derive = vide.derive - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root - do CASE "does not register dependency" local a = source(0) local b = source(0) @@ -1737,8 +2248,6 @@ TEST("untrack()", wrap_root(function() end)) TEST("events", function() - local create = vide.create - local function Thing(props) local instance = Instance.new("Thing") instance.Signal = Signal.new() @@ -1765,9 +2274,6 @@ TEST("events", function() end) TEST("actions", function() - local create = vide.create - local action = vide.action - do CASE "run action" local ran = false @@ -1801,11 +2307,6 @@ TEST("actions", function() end) TEST("changed()", wrap_root(function() - local root = vide.root - local create = vide.create - local source = vide.source - local changed = vide.changed - do CASE "outputs" local output = source(nil) @@ -1840,11 +2341,6 @@ TEST("changed()", wrap_root(function() end)) TEST("batch()", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local batch = vide.batch - do CASE "evaluation deferred" local a = source(0) @@ -2128,10 +2624,6 @@ TEST("batch()", wrap_root(function() end)) TEST("read()", wrap_root(function() - local source = vide.source - local effect = vide.effect - local read = vide.read :: any -- todo - do CASE "read primitive" CHECK(read(1) == 1) end @@ -2141,7 +2633,7 @@ TEST("read()", wrap_root(function() CHECK(read(src) == 1) end - do CASE "push_child_to_scope source" + do CASE "push_scope_as_child_of source" local src = source(0) local count = 0 @@ -2156,12 +2648,6 @@ TEST("read()", wrap_root(function() end)) TEST("context()", function() - local root = vide.root - local context = vide.context - local effect = vide.effect - local untrack = vide.untrack - local show = vide.show - do CASE "set context" local ctx = context() @@ -2192,10 +2678,12 @@ TEST("context()", function() CHECK(ctx() == 1) root(function() - ctx(2, function() + local v = ctx(2, function() CHECK(ctx() == 2) + return ctx() end) + CHECK(v == 2) CHECK(ctx() == 1) end) end @@ -2258,13 +2746,6 @@ TEST("context()", function() end) TEST("nested effects cases", function() - local vide = require "src/init" - local source = vide.source - local effect = vide.effect - local untrack = vide.untrack - local cleanup = vide.cleanup - local root = vide.root - local ran = 0 local cleaned = 0 @@ -2306,11 +2787,6 @@ TEST("nested effects cases", function() end) TEST("graph edge cases", wrap_root(function() - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local root = vide.root - do CASE "diamond A,B,C,D" --[[ @@ -2481,12 +2957,6 @@ end)) TEST("strict", wrap_root(function() vide.strict = true - local create = vide.create - local source = vide.source - local derive = vide.derive - local effect = vide.effect - local indexes, values = vide.indexes, vide.values - do CASE "error on derived callback yield" local src = source(1) @@ -2541,16 +3011,6 @@ TEST("strict", wrap_root(function() CHECK(count == 4) end - do CASE "indexes() error if primitive" - local src = source { 1 } - - local ok = pcall(function() - indexes(src, function() return 1 end) - end) - - CHECK(not ok) - end - do CASE "values() error if duplicate" local src = source { 1, 2, 1 } @@ -2625,6 +3085,78 @@ TEST("strict", wrap_root(function() CHECK(count == 4) end + + do CASE "destruction of active scope" + local src = source(false) + local count = 0 + + root(function() + show(src, function() + src(false) + cleanup(function() count += 1 end) + return {} + end) + end) + + local ok = pcall(function() + src(true) + end) + + CHECK(count == 0) + CHECK(not ok) + end + + do CASE "destruction of active scope in indexes" + local src = source {} + + local count_1 = 0 + local count_2 = 0 + + root(function() + effect(function() + untrack(function() + indexes(src, function() + cleanup(function() count_1 += 1 end) + src {} + cleanup(function() count_2 += 1 end) + return {} + end) + end) + end) + end) + + local ok = pcall(function() + src { 1 } + end) + + CHECK(not ok) + end + + do CASE "destruction of active scope in values" + local src = source {} + + local count_1 = 0 + local count_2 = 0 + + root(function() + effect(function() + untrack(function() + values(src, function() + cleanup(function() count_1 += 1 end) + src {} + cleanup(function() count_2 += 1 end) + return {} + end) + end) + end) + end) + + local ok = pcall(function() + src { {} } + end) + + CHECK(not ok) + end end)) local ok = FINISH() diff --git a/wally.toml b/wally.toml index a22ab5d..1584e06 100644 --- a/wally.toml +++ b/wally.toml @@ -2,7 +2,7 @@ name = "centau/vide" description = "A reactive Luau library for creating UI. " license = "MIT" -version = "0.3.0" +version = "0.4.1" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" include = ["default.project.json", "LICENSE", "src"]