commit e76234feb58fe76556cb06ba85ea28162d00390b Author: aaron <83140718+centau@users.noreply.github.com> Date: Thu Apr 6 02:54:21 2023 +0100 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..876fbd3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.luau linguist-language=Lua diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..169910b --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,30 @@ +name: unit-test +on: + push: + paths: + - src/** + - test/** + pull_request: + paths: + - src/** + - test/** +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Install Luau zip + uses: robinraju/release-downloader@v1.6 + with: + repository: Roblox/luau + latest: true + fileName: luau-ubuntu.zip + out-file-path: bin + + - name: Unzip Luau + run: unzip bin/luau-ubuntu.zip -d bin + + - name: Run unit tests + run: bin/luau test/tests.luau diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfe6dc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +sourcemap.json +.vscode +_local diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..34e951c --- /dev/null +++ b/.luaurc @@ -0,0 +1,6 @@ +{ + "languageMode": "strict", + "lint": { "BuiltinGlobalWrite": false, "UnknownGlobal": false }, + "globals": [ "aa" ] +} + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..28c648f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## Unreleased + +--- + +## [0.4.0] - 2023-01-26 + +### Added + +- Component grouping and `Registry:group()`. +- Method `View:use()`. +- Constant `ecr.null`. + +### Removed + +- Method `View:include()` and `Observer:include()`. + +### Fixed + +- `Registry:add()` not firing `Registry:added()` signals. +- Mismatch between argument list and values returned in multi-typed observers. +- Observers not garbage collecting after calling `Observer:disconnect()`. + +### Improved + +- Connection firing speed by ~70%. + +--- + +## [0.3.0] - 2023-01-09 + +### Added + +- Overload for `Registry:create()` to create an entity with a given identifier. + +### Changed + +- Registry signals no longer pass the registry as the first argument to listeners. +- Observers no longer track entities with removed components. +- Method `Registry:entities()` now creates and returns an array of only valid entities. +- Function `ecr.registry()` can no longer pre-allocate memory. + +### Removed + +- Method `Registry:capacity()`. + +### Improved + +- Double-type view iteration speed by ~100%. + +--- + +## [0.2.0] - 2022-12-08 + +### Added + +- Method `View:include()` and `Observer:include()`. +- Method `Registry:patch()`. +- Method `Registry:add()` and optional default parameter for `ecr.component()`. + +### Changed + +- Behavior `for ... in View do` now behaves the same as `for ... in View:each() do`. +- Signal diconnect API (Signal now returns a connection object to call disconnect on). + +### Improved + +- Entity creation and release speed by ~100%. +- Multi-type view iteration speed by ~60%. + +## [0.1.0] - 2022-11-16 + +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..57a41d1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 centau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7c532c --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Vide + +Vide is a declarative, reactive Luau library for building user interfaces on Roblox. + +## API Reference + +### [Reactivity: Core](docs/api/reactivity-core) + +- [wrap()](docs/api/reactivity-core#wrap) +- [derive()](docs/api/reactivity-core#derive) +- [foreach()](docs/api/reactivity-core#wrap) +- [match()](docs/api/reactivity-core#match) +- [watch()](docs/api/reactivity-core#watch) + +### [Reactivity: Utility](docs/api/reactivity-utility) + +- [isState()](docs/api/reactivity-utility#isState) +- [unwrap()](docs/api/reactivity-utility#unwrap) +- [readonly()](docs/api/reactivity-utility#readonly) +- [mutate()](docs/api/reactivity-utility#mutate) + +### [Element Creation](docs/api/creation) + +- [create()](docs/api/creation#create) +- [apply()](docs/api/creation#apply) +- [Layout](docs/api/creation#Layout) +- [Children](docs/api/creation#Children) +- [Event](docs/api/creation#Event) +- [Changed](docs/api/creation#Changed) +- [Bind](docs/api/creation#Bind) +- [Created](docs/api/creation#Created) + +### [Animation](docs/api/animation) + +- [spring()](docs/api/animation#spring) + +### [Types](docs/api/types) + +- [State](docs/api/types#State) +- [Prop](docs/api/types#Prop) + +
+ +## Tutorials + +### [Crash Course](docs/tutorials/crash-course) + +### [Reactive Graph](docs/tutorials/reactive-graph) diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..a3d7a6d --- /dev/null +++ b/aftman.toml @@ -0,0 +1,7 @@ +# This file lists tools managed by Aftman, a cross-platform toolchain manager. +# For more information, see https://github.com/LPGhatguy/aftman + +# To add a new tool, add an entry to this table. +[tools] +rojo = "rojo-rbx/rojo@7.2.1" +# rojo = "rojo-rbx/rojo@6.2.0" \ No newline at end of file diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..ce9aa38 --- /dev/null +++ b/default.project.json @@ -0,0 +1,4 @@ +{ + "name": "vide", + "tree": { "$path": "src" } +} diff --git a/docs/api/animation.md b/docs/api/animation.md new file mode 100644 index 0000000..8370d14 --- /dev/null +++ b/docs/api/animation.md @@ -0,0 +1,56 @@ +# Animation API + +
+ +## spring() + +Returns a new state with an animated value of the original. + +### Type + +```lua +function spring(state: State, period: number, dampingRatio: number = 1): State +``` + +### Details + +The output state's value is updated every frame based on the current input state's value. + +The change is physically simulated according to a [spring](https://en.wikipedia.org/wiki/Simple_harmonic_motion). + +`period` is the amount of time in seconds it takes for the spring to complete one full cycle + +`dampingRatio` is relared to the amount of resistant force applied to the spring. + +- \>1 = Overdamped (Not currently supported) +- 1 = Critically damped +- <1 = Underdamped +- 0 = Undamped + +Velocity is conserved between input state updates for smooth animation. + +### Example + +```lua +local state = wrap(1) + +local springed = spring(state, 1, 1) +``` + +
Example of an animated counter + +```lua +local count = wrap(1000) + +local function Counter(props) + local tweenedCount = spring(count, 0.5, 1) + + return create("TextLabel") { + Text = "Count: " .. tweenedCount + } +end +``` + +
+ +------------------------------------------------------------------- diff --git a/docs/api/creation.md b/docs/api/creation.md new file mode 100644 index 0000000..7ca416e --- /dev/null +++ b/docs/api/creation.md @@ -0,0 +1,325 @@ +# Element Creation API + +
+ +## create() + +Creates a new UI element, applying any given properties. + +### Type + +```lua +function create(classNameOrInstance: string | Instance): (properties: Map) -> Instance +``` + +### Details + +The function can take either a `string` or an `Instance` as its first argument. + +- If given a `string`, a new instance with the string class name will be created with default properties already applied. +- If given an `Instance`, a new instance that is a clone of the given instance will be created. + +This returns another function that is used to apply any properties to the new instance. + +### Example + +```lua +local frame = create("Frame") { + Name = "NewFrame", + Position = UDim2.fromScale(1, 0) +} + +-- creates a clone of `frame` with new properties applied. +local frame2 = create(frame) { + Size = UDim2.fromOffset(50, 100) +} +``` + +------------------------------------------------------------------- + +
+ +## apply() + +Applies any given properties to a given instance. + +### Type + +```lua +function apply(instance: Instance): (properties: Map) -> Instance +``` + +### Details + +Applies properties in the same manner as `create` for already existing existances. + +Can use symbols and bind state just like `create`. + +### Example + +```lua +local frame = Instance.new("Frame") + +apply(frame) { + Position = UDim2.fromScale(1, 0) +} +``` + +------------------------------------------------------------------- + +
+ +## Layout + +Symbol used to pass layout properties to elements. + +### Type + +```lua +type Layout = Symbol + +type LayoutProps = { + [Symbol] = { + -- These are all properties considered to be "layout properties" + AnchorPoint: Prop?; + LayoutOrder: Prop?; + Position: Prop?; + Rotation: Prop?; + Size: Prop?; + SizeConstraint: Prop?; + Visible: Prop?; + ZIndex: Prop?; + } +} + +type Prop = T | State +``` + +### Details + +The primary purpose of this symbol is to enable easy passthrough of layout properties +through user-defined component hierarchies. + +It is recommended to only set layout properties using the `Layout` symbol when using +your own components. + +### Example + +```lua +local function BlackFrame(props) + return create("Frame") { + BackgroundColor3 = Color3.new(0, 0, 0), + [Layout] = props[Layout] + } +end + +BlackFrame { + [Layout] = { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromOffset(100, 50) + } +} +``` + +------------------------------------------------------------------- + +
+ +## Children + +Symbol used to pass child instances to elements. + +### Type + +```lua +type Children = Symbol + +type ChildrenProps = { + [Symbol] = ChildrenProp +} + +type ChildrenProp = Prop | Array +``` + +### Details + +This symbol is flexible in the way that children can be passed in the form of nested arrays. + +Children can also be assigned using a state binding. + +### Example + +```lua +create("Frame") { + -- all of the below are valid methods of assigning children + [Children] = create("TextLabel") {}, + + [Children] = { + create("TextLabel") {}, + create("TextLabel") {}, + }, + + [Children] = { + create("TextLabel") {}, + { + create("TextLabel") {}, + } + } +} +``` + +------------------------------------------------------------------- + +
+ +## Event + +Symbol used to connect callbacks to instance events. + +### Type + +```lua +type Event = Map + +type EventProps = { + [Symbol] = Prop<(...unknown) -> ()> +} +``` + +### Details + +The `Event` symbol can be indexed to get symbols to connect to specific events. + +Event parameters are passed into the callback. + +When callbacks are connected by binding to a state, +connections are automatically disconnected when the state changes. + +### Example + +```lua +create("TextButton") { + [Event.Activated] = function() + print("Clicked") + end +} +``` + +------------------------------------------------------------------- + +
+ +## Changed + +Symbol used to connect callbacks to instance property changed events. + +### Type + +```lua +type Changed = Map + +type ChangedProps = { + [Symbol] = Prop<(...unknown) -> ()> +} +``` + +### Details + +The `Changed` symbol can be indexed to get symbols to connect to specific events just like `Event`. + +Event parameters are passed into the callback. + +When callbacks are connected by binding to a state, +connections are automatically disconnected when the state changes. + +### Example + +```lua +create("TextBox") { + [Changed.Text] = function() + print("New text entered") + end +} +``` + +------------------------------------------------------------------- + +
+ +## Bind + +Symbol used to bind states to instance properties. + +### Type + +```lua +type Bind = Map + +type BindProps = { + [Symbol] = State +} +``` + +### Details + +The `Bind` symbol can be indexed to bind specific properties just like `Event`. + +Sets the given state value to the instance property value immediately after instance creation. + +When an instance property is changed, the value of the given state will automatically +be set to the new property. Effectively a shorthand for connecting a property changed event +to set state values. + +### Example + +```lua +local text = wrap() + +local box = create("TextBox") { + [Bind.Text] = text +} + +box.Text = "New text" +print(text.Value) -- "New text" +``` + +------------------------------------------------------------------- + +
+ +## Created + +Symbol used to run a callback once immediately after instance creation. + +### Type + +```lua +type Created = Symbol + +type CreatedProps = { + [Symbol] = (Instance) -> () +} +``` + +### Details + +The instance being defined with the `Created` symbol is passed as the first +argument to the callback. + +### Example + +```lua +local frame + +create("Frame") { + Name = "Background", + [Created] = function(instance) + frame = instance + end +} + +print(frame.Name) -- "Background" +``` + +------------------------------------------------------------------- diff --git a/docs/api/reactivity-core.md b/docs/api/reactivity-core.md new file mode 100644 index 0000000..4f91693 --- /dev/null +++ b/docs/api/reactivity-core.md @@ -0,0 +1,249 @@ +# Reactivity API: Core + +
+ +## wrap() + +Wraps and returns any given values with reactive state objects. + +### Type + +```lua +function wrap(value: T): State +function wrap(value: ...unknown): ...State + +type State = { + Value: T, + value: T +} +``` + +### Details + +The state object has a single mutable field `.Value`. + +Read operations to `.Value` are tracked and write operations can trigger +dependency updates and watchers. + +### Example + +```lua +local count = wrap(0) + +print(count.Value) -- 0 + +count.Value += 1 + +print(count.Value) -- 1 +``` + +------------------------------------------------------------------- + +
+ +## derive() + +Derives a new reactive state object from an existing state object. + +### Type + +```lua +function derive( + (from) -> T, + cleanup: (value: T) -> ()? +): State + +function from(T | State): T +``` + +### Details + +The derived state will have its value recalculated when any state it derives from is updated. + +Takes a callback that is immediately run to determine what states are being referenced. Only states referenced in the immediate function scope can trigger updates. + +The state object returned by this function is readonly. + +Has an optional cleanup parameter which takes a function that is called with the old value any +time the derived state recalculates a value. + +An optional utility function is passed as the first argument to the callback, if given a state, the value of the state will be returned (changes to this state still triggers updates unlike `unwrap`), if given a value the value is returned. + +> ⚠️ The callback cannot yield. + +### Example + +```lua +local count = wrap(0) +local text = derive(function() return "Count: "..count.Value end) + +print(text.Value) -- "Count: 0" + +count.Value += 1 + +print(text.Value) -- "Count: 1" +``` + +```lua +local count = wrap(0) +local text = derive(function(from) + return "Count: "..from(count) +end) +``` + +A shorthand method for deriving states also exists, following example is equivalent to the above: + +```lua +local count = wrap(0) +local text = "Count: "..count -- all binary operators are supported +``` + +------------------------------------------------------------------- + +
+ +## foreach() + +Derives a new state object from an existing state object. +Designed to work specifically with table states. + +Also works with non state tables. + +### Type + +```lua +function foreach( -- number as first arg + i: number, + transform: (key: number) -> (KO, VO), + cleanup: (KO, VO) -> ()? +): Map + +function foreach( -- table as first arg + table: Map, + transform: (key: KI, value: VI) -> (KO, VO), + cleanup: (KO, VO) -> ()? +): Map + +function foreach( -- state as first arg + state: State>, + transform: (key: KI, value: VI) -> (KO, VO), + cleanup: (KO, VO) -> ()? +): State> +``` + +### Details + +When the state being derived from is updated, the derived state will +recompute by applying its transform function to each key-value pair. + +Will only be recomputed if the corresponding key differs between updates. + +Has an optional cleanup function to cleanup the old key and value. + +> ⚠️ The transform function cannot yield. + +### Example + +```lua +local numbers = wrap { 1, 2, 3 } +local plusOne = foreach(numbers, function(i, v) + return i, v + 1 +end) + +print(plusOne.Value) -- { [1]: 2, [2]: 3, [3]: 4 } + +-- note that assignment must take place to trigger reactive updates. +-- modifying the value without assignment `numbers.Value[2] = 5` will not trigger updates. +numbers.Value = { 1, 5, 3 } + +print(plusOne.Value) -- { [1]: 2, [2]: 6, [3]: 4 } +``` + +------------------------------------------------------------------- + +
+ +## match() + +Derives a new state object from an existing state object. +Similar to switch statements in other languages. + +### Type + +```lua +function match(value: K): (transform: Map) -> V +function Match(state: State): (transform: Map) -> State +``` + +### Details + +When the state being derived from is updated, the derived state will +recompute by using the input value as a key to map to an output value. + +### Example + +```lua +local state = wrap(true) +local matched = match(state) { + [true] = 1, + [false] = 0 +} + +print(matched.Value) -- 1 + +state.Value = false + +print(matched.Value) -- 0 +``` + +------------------------------------------------------------------- + +
+ +## watch() + +Runs a callback on state change. + +### Type + +```lua +function watch(callback: () -> Cleanup?): Unwatch + +type Cleanup = () -> () +type Unwatch = () -> () +``` + +### Details + +The callback is ran immediately to determine what states to watch. + +Any time a state read in the watch callback is changed, the watcher callback will be deferred +to the end of the resumption cycle and ran. + +Only states in the immediate function scope can trigger the watch callback. + +Watchers are run *before* UI properties are updated. + +The callback can return an optional cleanup function that is run each time the watcher is rerun. + +Also returns a function that when called, stops the watcher immediately (also runs cleanup if any was given). + +> ⚠️ The callback cannot yield. + +### Example + +```lua +local state = wrap(1) + +watch(function() + print(state.Value) +end) + +-- prints 1 + +state.Value += 1 + +-- prints 2 +``` + +------------------------------------------------------------------- diff --git a/docs/api/reactivity-utility.md b/docs/api/reactivity-utility.md new file mode 100644 index 0000000..885b4da --- /dev/null +++ b/docs/api/reactivity-utility.md @@ -0,0 +1,152 @@ +# Reactivity API: Utility + +
+ +## isState() + +Determines if a given value is a state object or not. + +### Type + +```lua +function isState(value: unknown): boolean +``` + +### Example + +```lua +local value = wrap() + +print(isState(value)) -- true + +value = 0 + +print(isState(value)) -- false +``` + +------------------------------------------------------------------- + +
+ +## unwrap() + +Unwraps a state and returns its stored value. + +### Type + +```lua +function unwrap(value: T | State): T +``` + +### Details + +If given a state, the state's stored value will be returned. + +Unwrapping a state within a derived callback will not trigger updates. + +Can be given a non-state value, in which case the same value will just be returned. + +### Example + +```lua +local state = wrap(1) + +print(unwrap(state)) -- 1 + +print(unwrap(1)) -- 1 +``` + +------------------------------------------------------------------- + +
+ +## readonly() + +Creates a new derived state with the same value as the state being derived from. + +Used to create readonly states. + +### Type + +```lua +function readonly(state: State): State +``` + +### Example + +```lua +local count = wrap(1) +local read = readonly(count) + +print(read.Value) -- 1 + +count.Value += 1 + +print(read.Value) -- 2 + +read.Value += 1 -- error +``` + +------------------------------------------------------------------- + +
+ +## mutate() + +Mutates a given state's value and updated any derived states. + +### Type + +```lua +function mutate(value: T | State): T +``` + +### Details + +Since states only update derived states if a new value is set (tables are compared by reference), +this function serves as a way to trigger derived state updates if a state's value is not changed but +instead mutated. + +Can also take non-state as an argument. + +### Example + +```lua +local state = wrap { Count = 1 } + +local derived = derive(function() + return state.Value.Count +end) + +mutate(state, function(value) + value.Count += 1 +end) + +print(derived.Value) -- 2 +``` + +
Motivation for this function + +```lua +local state = wrap { Count = 1 } + +local derived = derive(function() + return state.Value.Count +end) + +state.Value.Count += 1 + +print(derived.Value) -- still 1 because `state.Value` was never set with a new value so change wasn't detected + +local value = state.Value +value.Count += 1 +state.Value = value + +print(derived.Value) -- still 1 because although `state.Value` was set, when the new value set was compared, +-- it was still the same as the previous (tables are compared by reference not their contents) +-- and so no update was made +``` + +
+ +------------------------------------------------------------------- diff --git a/docs/api/strict.md b/docs/api/strict.md new file mode 100644 index 0000000..42d3dc0 --- /dev/null +++ b/docs/api/strict.md @@ -0,0 +1,57 @@ +# Strict Mode + +
+ +## strict + +A flag that users can set to enable or disable strict mode (disabled by default). + +### Type + +```ts +boolean strict = false +``` + +### Details + +The purpose of strict mode is to help ensure stateful code is *pure* (deterministic and free from side effects). + +Setting this flag is global for all scripts requiring the same instance of the Vide module. + +What strict mode will do: + +1. Run derived callbacks twice when calculating state value. +2. Run watcher callbacks twice each time state changes. +3. Throw an error if a derived callback yields. +4. Throw an error if a watcher callback yields. + +It is recommend to develop UI with strict mode set to `true` +and to set it back to false when pushing to production. + +Using strict mode will help identify potential non-deterministic code and side-effects by running code +multiple times in places where it would only run once. + +Strict mode will also ensure that watcher side effects are self contained in the sense that they clean themselves up +properly when ran multiple times in quick succession, in case any asynchronous operation is performed. + +Yielding within derived or watcher callbacks can cause undefined behavior as the reactive graph is not designed +to work with asynchronous code. Strict mode can identify and throw an error when asynchronous code is detected. +This isn't done during runtime as these checks are computationally expensive. + +### Example + +```lua +vide.strict = true -- this only needs to be done once, preferably in the first module to require Vide + +local state = wrap() + +watch(function() + local cleanup = doAsyncOperation(state.Value) + + return function() + cleanup() + end +end) + +state.Value = 1 -- this will cause the watcher to be ran twice, identifying if cleanup occurs properly +``` diff --git a/docs/api/types.md b/docs/api/types.md new file mode 100644 index 0000000..66e1e7b --- /dev/null +++ b/docs/api/types.md @@ -0,0 +1,55 @@ +# Types API + +
+ +## State\ + +A type representing a Vide state object. + +### Type + +```lua +type State = { + Value: T, + value: T +} +``` + +### Example + +```lua +local state: State = wrap(1) + +local derived: State = "Count: " .. state +``` + +------------------------------------------------------------------- + +
+ +## Prop\ + +A utility type representing a union of a value and a state. + +### Type + +```lua +type Prop = T | State +``` + +### Example + +```lua +type BackgroundProps = { + Position: Prop, + Size: Prop +} +local function Background(props: { Position: Prop }) + return create("Frame") { + Position = props.Position + Size = props.Size + } +end +``` + +------------------------------------------------------------------- diff --git a/docs/tut/crash-course.md b/docs/tut/crash-course.md new file mode 100644 index 0000000..95551aa --- /dev/null +++ b/docs/tut/crash-course.md @@ -0,0 +1,337 @@ +# Vide Crash Course + +Hello! This is a brief tutorial designed to give you a quick runthrough of the usage of Vide. + +Vide is inspired by the popular libraries Vue and Fusion. + +- Note that this tutorial assumes that you are familiar with Luau and the Roblox UI system. + +
+ +## Creating UI Instances + +In Vide, it is intended to create all UI instances through code. + +Instances are created using [`vide.create`](../api/creation#create). + +```lua +local vide = require(...) +local create = vide.create +``` + +```lua +local frame = create("Frame") { + Name = "Background", + Position = UDim2.fromScale(0.5, 0.5) +} +``` + +The function returns a constructor for a given class which then takes a table of properties to assign to create a new instance for that class. + +Sometimes you want to do more than setting properties, such as setting children or connecting to events. +Vide uses special keys called *symbols* which provide unique functionality like the above mentioned. + +Children can be assigned to instances using the `Children` symbol. + +```lua +local Children = vide.Children +``` + +```lua +local screenGui = create("ScreenGui") { + Parent = game.StarterGui, + + [Children] = create("Frame") { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.fromScale(0.4, 0.7), + + [Children] = { + create("TextLabel") { + Text = "Hi" + }, + + create("TextLabel") { + Text = "Bye" + } + } + } +} +``` + +Here, we import the symbol [`vide.Children`](../api/creation#Children). + +This symbol can accept an instance, an array of instances and nested arrays of instances. +All given instances will be parented to the instance the symbol was used on. + +
+ +## Connecting To Events + +Built-in instance events and property changed events can be connected to using two other symbols, [`vide.Event`](../api/creation#Event) and [`vide.Changed`](../api/creation#Changed). + +```lua +local Event = vide.Event +local Changed = vide.Changed +``` + +```lua +local textBox = create("TextBox") { + PlaceholderText = "Enter text", + + [Event.Focused] = function(...) + print("User is focusing on text box") + end, + + [Changed.Text] = function(newText) + print("New text: " .. newText) + end +} +``` + +Both of these symbols can be indexed into to get a specific symbol for an event to connect to. +The callback function for `Event` receives any event-specific arguments and the callback function for +`Changed` receives the new property value as the only argument (unlike `Instance:GetPropertyChangedSignal()`). + +
+ +## State + +*State* is the condition something is in at a specific time. The state of a program is simply the data it contains at some timepoint. + +The purpose of all UI is to take some state and reflect that state visually. + +In Vide, UI state is represented using special objects simply called *state*. + +A state object in Vide can be created using [`vide.wrap`](../api/reactivity-core#wrap). + +```lua +local wrap = vide.wrap +``` + +```lua +local isVisible = wrap(false) + +local image = create("ImageLabel") { + Image = "rbxassetid://xxx", + Visible = isVisible +} + +while true do + wait(1) + isVisible.Value = not isVisible.Value +end +``` + +The function `wrap` will *wrap* any given value with a state object of type `State` which can be read from/wrote to through its `.value` property. + +In the above code, the `ImageLabel.Visible` property is assigned a state. Now any time that state's value is assigned to, `ImageLabel.Visible` will also update with the new value assigned, without you having to explicitly set the property. The above code gives the effect of the image label toggling visibility at a 1 second interval forever. + + +There are a few reasons why we use state objects instead of plain variables: + +1. Vide detects when you assign a state object as a property value. This is known as *binding* and doing so will cause the property to *automatically* update whenever that state object's value is changed. +2. We can create new state objects that derive from other state objects, which again, *automatically* update when the derived state objects change. + +The reason why this is useful, is that you as the programmer do not have to worry about manually updating variables or UI instances, you can just focus on defining how the data maps to UI and everything will automatically update when changes occur. + +
+ +## Derived State + +You can create new state from other states. This is known as *deriving state*. + +```lua +local derive = vide.derive +``` + +```lua +local count = wrap(0) + +local text = derive(function(from) + return "Count: " .. from(count) +end) + +print(text.value) -- "Count: 0" + +count.value += 1 + +print(text.value) -- "Count: 1" +``` + +Here we use [`vide.derive`](../api/reactivity-core#derive) to *derive* a new state `text` which depends on `count`. + +A function is used to transform the value of `count`, where the value returned becomes the new value of `text`. The function receives an argument named `from` which is used to *capture* dependent states. This is used to link `count` to `text`, so that whenever `count` is updated, `text` will be too. + +Whenever `count`'s value is changed, `text` will recompute its value and update anything dependent on `text`, such as UI. + +States can be derived in a more concise manner when doing single operations such as concatenation: + +```lua +local text = "Count: " .. count +``` + +You can derive new states using any Luau operator in this manner. + +
+ +## Components + +*Components* in UI are just custom-made reusable pieces of UI made from other pieces of UI. + +The recommended way to create components is to use functions that take a table of properties as an argument and return the new UI instance. + +```lua +local function Background(args) + return create("Frame") { + BackgroundColor3 = Color3.new(0, 0, 0), + Position = args.Position, + Size = args.Size + } +end + +local background = Background { + Position = UDim2.new(), + Size = UDim2.new() +} +``` + +Above is a simple example of a frame component with its background color set to black. + +A single parameter `args` is used to pass properties to the component. + +Components allow you to *encapsulate* behavior. You can only modify the component in ways that are defined in the component. +Looking at the above example, the only properties you are allowed to modify is `Position` and `Size`. +This is a good approach to use for organised code. + +However, properties concering layout (positional and size properties) aren't usually intrinsinc to the component. In most cases the user would want to be able to pass these properties without having to manually pass each one in the component. + +For these cases, the [`vide.Layout`](../api/creation#Layout) symbol can be used. + +```lua +local Layout = vide.Layout +``` + +```lua +local function Background(props) + return create("Frame") { + BackgroundColor3 = Color3.new(0, 0, 0), + [Layout] = props[Layout], + [Children] = props[Children] + } +end + +local background = Background { + [Layout] = { + AnchorPoint = Vector2.new(), + Position = UDim2.new(), + Size = UDim2.new(), + }, + + [Children] = { + create("TextLabel") {}, + create("ImageLabel") {} + } +} +``` + +Here, the `Layout` symbol automatically assigns those layout-specific properties without having to explicitly assign each one in the component definition. This is a very common case and for this reason it is recommended to only assign layout properties using the `Layout` symbol for consistency when dealing with components. + +This allows you to pass through layout properties without breaking encapsulation. + +Additionally, the above example shows how children can be passed to components in a similar manner. + +
+ +## Stateful components + +Often, you need components that maintain their own internal state, such as a toggle button or a counter. + +Below you can see how a simple counter component can be implemented. + +```lua +local function Counter(args) + -- create internal state unique to each component instance + local count = wrap(0) + + return create("TextButton") { + Name = "Counter", + + Text = "Count: " .. count + + [Event.Activated] = function() + count.value += 1 + end + + [Layout] = args[Layout] + } +end + +create "ScreenGui" { + Parent = game.StarterGui, + + [Children] = { + Counter { + [Layout] = { + AnchorPoint = Vector2.new(0.5, 0), + Position = UDim2.fromScale(0.5, 0), + Size = UDim2.fromScale(0.3, 0.1) + } + } + } +} +``` + +Here a reusable counter component is created, that when clicked on will increase its count and display it independent from other counter instances. + +## Tables of data + +basic inventory + +```lua +type Item = { + Name: string, + Icon: number +} + +local items = wrap({} :: Array) + +local function ItemSlot(args) + return create("Frame") { + [Layout] = args[Layout] + + [Children] = { + create("TextLabel") { + Name = args.Name, + [Layout] = ... + }, + + create("ImageLabel") { + Image = "rbxassetid://" .. args.Icon, + [Layout] = ... + } + } + } +end + +local function Inventory(args) + return create("Frame") { + [Layout] = args[Layout], + + [Children] = { + create("UIListLayout") {}, + + map(items, function(i, item) + return ItemSlot { + Name = item.Name, + Icon = item.Icon, + [Layout] = { LayoutOrder = i, ... } + } + end) + } + } +end + + + +More comprehensive tutorials are in the works. To find out more refer to the [`API documentation`](../../README#API). diff --git a/docs/tut/reactive-graph.md b/docs/tut/reactive-graph.md new file mode 100644 index 0000000..cc0e4ca --- /dev/null +++ b/docs/tut/reactive-graph.md @@ -0,0 +1,99 @@ +# Vide Reactive Graph + +Details on how Vide's reactive graph works. + +## Nodes + +A "node" refers to a point on the reactive graph. + +- Nodes can have parents and children. +- Updating a node will mark all descendant nodes for update. +- Each Vide state object acts as a node on the reactive graph. + +Vide's reactive graph uses a *lazy evaluation* model, meaning that +if a node with children is updated, the new value for the child node +is not recalculated immediately. Only when something attempts to access +the child's value is it recalculated. + +## Example + +Below is an (*overengineered*) example to demonstrate how the reactive graph functions. +States are used here to model the various transforms done on two inputs, `health` and `maxHealth` +to represent player health for UI. + +```lua +local health = wrap(90) +local maxHealth = wrap(100) + +local healthTweened = spring(health, 0.5) +local text = "Health: " .. healthTweened + +local ratio = health / maxHealth + +local barSize = derive(function(from) + return UDim2.fromScale(from(ratio), 1) +end +``` + +Below is a graphical representation of the reactive graph formed by the above code. + +```mermaid +flowchart LR + A(( )) + B(( )) + A --> health + B --> maxHealth + + health --> healthTweened + healthTweened --> text + + health --> ratio + maxHealth --> ratio + ratio --> barSize +``` + +When states are initially derived, all values are known. + +Say if the player is damaged, and the `health` node changes value. +All descendant nodes from `health` will be marked as updated. +The nodes marked as updated are represented by the broken lines below. + +```mermaid +flowchart LR + A(( )) + B(( )) + A --> health + B --> maxHealth + + health .-x healthTweened + healthTweened .-x text + + health .-x ratio + maxHealth --> ratio + ratio .-x barSize +``` + +When something tries to read the value of the node `text`, a recalculation occurs. +While `text` is being recalculated, `healthTweened` will be read from, causing it to be recalculated as well. +This results in a chain that propogates up the reactive graph until all ancestors are up to date. + +Below is what the graph will look like after `text` has been recalculated. + +```mermaid +flowchart LR + A(( )) + B(( )) + A --> health + B --> maxHealth + + health --> healthTweened + healthTweened --> text + + health .-x ratio + maxHealth --> ratio + ratio .-x barSize +``` + +Lazy evaluation is a useful model as it saves unecessary calculation, only calculating when needed. + +Looking at stateful code as a reactive graph is a good way to mentally picture how your data maps to UI. diff --git a/docs/tut/tmp.md b/docs/tut/tmp.md new file mode 100644 index 0000000..e0e0091 --- /dev/null +++ b/docs/tut/tmp.md @@ -0,0 +1,20 @@ +```lua +local function Text(args) + return create("TextLabel") { + [Layout] = { + Size = scale(1), + args[Layout] + } + } +end + +Text { + [Layout] = { + Position = scale(0.5, 0.1) + } +} +``` + +```lua +a +``` diff --git a/src/Change.lua b/src/Change.lua new file mode 100644 index 0000000..17c83b7 --- /dev/null +++ b/src/Change.lua @@ -0,0 +1,55 @@ +------------------------------------------------------------------------------------------ +-- vide/Change.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local memoize = require(script.Parent.memoize) +local bind = require(script.Parent.bind) + +local graph = require(script.Parent.graph) +type State = graph.State +type MaybeState = graph.MaybeState +local create = graph.create +local get = graph.get +local link = graph.link +local wrapped = graph.wrapped + +local Types = require(script.Parent.Types) + +type Listener = (unknown) -> () + +local getChangeSymbol = memoize(function(name: string): Types.Symbol> + return { + priority = 2, + run = function(instance: Instance, listener: MaybeState) + local event: RBXScriptSignal = instance:GetPropertyChangedSignal(name) + + if type(listener) == "function" then + event:Connect(function() + listener( (instance :: any)[name] ) + end) + elseif wrapped(listener) then + local state = create(nil) + + link(listener :: State, state, function() + local newListener = get(listener :: State) + return newListener and function() + newListener( (instance :: any)[name] ) + end + end) + + state.updated = true + bind.event(state :: State, instance, event) + else + error("Attempt to connect non-function to changed event", 2) + end + end + } +end) + +local Changed = table.freeze(setmetatable({}, {__index = function(_, index: string) + return getChangeSymbol(index) +end})) :: any + +return Changed :: { [string]: unknown } diff --git a/src/Children.lua b/src/Children.lua new file mode 100644 index 0000000..90dcc30 --- /dev/null +++ b/src/Children.lua @@ -0,0 +1,39 @@ +------------------------------------------------------------------------------------------ +-- vide/Children.lua +------------------------------------------------------------------------------------------ + +if not game then + script = (require :: any) "test/wrap-require" + typeof = require "test/mock".typeof +end + +local graph = require(script.Parent.graph) +local wrapped = graph.wrapped + +local throw = require(script.Parent.throw) +local bind = require(script.Parent.bind) +local Types = require(script.Parent.Types) + +type Children = Types.Children + +local function setChildren(instance: Instance, children: Children) + if typeof(children) == "Instance" then + if children.Parent then throw(`Cannot parent instance { instance.Name }, instance already parented`) end + children.Parent = instance + elseif wrapped(children) then + bind.children(children :: any, instance ) + elseif type(children) == "table" then + for _, child: Children in next, children do + setChildren(instance, child) + end + else + throw(`Cannot parent non-instance { typeof(children) }`) + end +end + +local Children = { + priority = 1, + run = setChildren +} :: Types.Symbol + +return Children :: unknown diff --git a/src/Created.lua b/src/Created.lua new file mode 100644 index 0000000..3e70387 --- /dev/null +++ b/src/Created.lua @@ -0,0 +1,16 @@ +------------------------------------------------------------------------------------------ +-- vide/Created.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local Types = require(script.Parent.Types) + +local Created = { + priority = 3, + run = function(instance: Instance, callback: (Instance) -> ()) + callback(instance) + end +} :: Types.Symbol<(Instance) -> ()> + +return Created :: unknown diff --git a/src/Event.lua b/src/Event.lua new file mode 100644 index 0000000..dc9dfe6 --- /dev/null +++ b/src/Event.lua @@ -0,0 +1,41 @@ +------------------------------------------------------------------------------------------ +-- vide/Event.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local memoize = require(script.Parent.memoize) +local bind = require(script.Parent.bind) + +local graph = require(script.Parent.graph) +type State = graph.State +type MaybeState = graph.MaybeState +local wrapped = graph.wrapped + +local Types = require(script.Parent.Types) + +type Listener = (unknown) -> () + +local getEventSymbol = memoize(function(name: string): Types.Symbol> + return { + priority = 2, + run = function(instance: Instance, listener: MaybeState) + local event: RBXScriptSignal<...unknown> = (instance :: any)[name] + + if type(listener) == "function" then + event:Connect(listener) + elseif wrapped(listener) then + bind.event(listener :: State, instance, event) + else + error("Attempt to connect non-function to event", 2) + end + end + } +end) + +local Event = table.freeze(setmetatable({}, {__index = function(_, index: string) + return getEventSymbol(index) +end})) :: any + +return Event :: { [string]: unknown } + diff --git a/src/Layout.lua b/src/Layout.lua new file mode 100644 index 0000000..67e78a3 --- /dev/null +++ b/src/Layout.lua @@ -0,0 +1,46 @@ +------------------------------------------------------------------------------------------ +-- vide/Layout.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +local wrapped = graph.wrapped + +local throw = require(script.Parent.throw) +local bind = require(script.Parent.bind) +local flags = require(script.Parent.flags) +local Types = require(script.Parent.Types) + +local layoutProperties = { + Parent = true, + AnchorPoint = true, + LayoutOrder = true, + Position = true, + Rotation = true, + Size = true, + SizeConstraint = true, + Visible = true, + ZIndex = true +} + +local function setLayout(instance: Instance, properties: { [string]: unknown }) + for property, value in next, properties do + if flags.strict and not layoutProperties[property] then + throw(`{ property } is not a valid layout property`) + end + + if wrapped(value) then + bind.property(value :: graph.State, instance, property) + else + (instance :: any)[property] = value + end + end +end + +local Layout = { + priority = 1, + run = setLayout +} :: Types.Symbol<{ [string]: unknown }> + +return Layout :: unknown diff --git a/src/Types.lua b/src/Types.lua new file mode 100644 index 0000000..5742eef --- /dev/null +++ b/src/Types.lua @@ -0,0 +1,19 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/Types.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) + +type State = graph.State +type MaybeState = graph.MaybeState + +export type Symbol = { + priority: number, + run: (Instance, T) -> () +} + +export type Children = Instance | State | { Children } + +return {} diff --git a/src/apply.lua b/src/apply.lua new file mode 100644 index 0000000..15db585 --- /dev/null +++ b/src/apply.lua @@ -0,0 +1,15 @@ +------------------------------------------------------------------------------------------ +-- vide/apply.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local applyProperties = require(script.Parent.applyProperties) + +local function apply(instance: T & Instance) + return function(properties: { [any]: unknown }): T + return applyProperties(instance, properties) + end +end + +return apply diff --git a/src/applyProperties.lua b/src/applyProperties.lua new file mode 100644 index 0000000..ee24896 --- /dev/null +++ b/src/applyProperties.lua @@ -0,0 +1,66 @@ +------------------------------------------------------------------------------------------ +-- vide/applyProperties.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +local wrapped = graph.wrapped + +local throw = require(script.Parent.throw) +local bind = require(script.Parent.bind) +local Types = require(script.Parent.Types) + +local function applyProperty(instance: Instance, property: string, value: unknown) + +end + +local function applyProperties(instance: T & Instance, properties: { [string|Types.Symbol ]: unknown }): T + local parent: unknown = properties.Parent + if parent then properties.Parent = nil end + + local eventBuffer: { [(Instance, () -> ()) -> ()]: () -> () } = {} -- connect events after setting properties + local postCreation: (Instance) -> ()? = nil -- buffer for post-creation callback + + for property, value in next, properties do + if type(property) == "string" then + if wrapped(value) then + bind.property(value :: State, instance, property) + else + (instance :: any)[property] = value + end + elseif type(property) == "table" then + local priority = property.priority + if priority == 1 then + property.run(instance, value) + elseif priority == 2 then + eventBuffer[property.run] = value :: () -> () + elseif priority == 3 then + assert(not postCreation) + postCreation = value :: () -> () + else + error("invalid priority") + end + else throw(`Invalid property { tostring(property) }, expected string or symbol`) end + end + + for fn, v in next, eventBuffer do + fn(instance, v) + end + + if parent then + applyProperty(instance, "Parent", parent) + if wrapped(parent) then + bind.parent(parent :: State, instance) + else + instance.Parent = parent :: Instance + end + end + + if postCreation then postCreation(instance) end + + return instance +end + +return applyProperties diff --git a/src/bind.lua b/src/bind.lua new file mode 100644 index 0000000..863aeec --- /dev/null +++ b/src/bind.lua @@ -0,0 +1,139 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/bind.lua +-------------------------------------------------------------------------------------------------------------- + +local warn = warn -- todo + +if not game then + script = (require :: any) "test/wrap-require" + warn = print +end + + +local graph = require(script.Parent.graph) +type State = graph.State +local get = graph.get +local setEffect = graph.setEffect + +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) + +local hold: { Instance? } = {} +local weak: { Instance? } = setmetatable({}, { __mode = "v" }) :: any +local bindcount = 0 + +local srcs do + local src1 = debug.info(1, "s") + local srctrunc = string.sub(src1, 1, #src1-4) + + srcs = { + src1, + srctrunc .. "applyProperties", + srctrunc .. "create", + srctrunc .. "apply" + } +end + +local function traceback() -- ensures trace begins outside of any vide library file + local s = 1 + repeat + s += 1 + local src = debug.info(s, "s") + until not table.find(srcs, src) + return debug.traceback("", s) +end + +function setup(state: State, instance: Instance, updateInstance: (Instance) -> ()) + if flags.strict then + local fn = updateInstance + local trace = traceback() + updateInstance = function(instance) + local ok, err: string? = pcall(fn, instance) + if not ok then warn(`error occured updating state binding:\n{err}\nset from:{trace}`) end + end + end + + updateInstance(instance) + setEffect(state, updateInstance, instance) + + bindcount += 1 + local key = bindcount + + weak[key] = instance + + local function ref() + local _ = state -- prevent gc of state while instance exists + local instance = weak[key] :: Instance + hold[key] = instance.Parent and instance or nil -- prevent gc of instance while parented + end + + ref() + instance:GetPropertyChangedSignal("Parent"):Connect(ref) +end + +local function bindProperty(state: State, instance_STRONG: Instance, property: string) + setup(state, instance_STRONG, function(instance) + (instance :: any)[property] = get(state) + end) +end + +local function bindParent(state: State, instance_STRONG) + instance_STRONG.Destroying:Connect(function() + instance_STRONG = nil :: any -- allow gc when destroyed + end) + setup(state, instance_STRONG, function(instance) + local _ = instance_STRONG -- state will strongly reference instance when parent is bound + instance.Parent = get(state) + end) +end + +local function bindChildren(state: State<{ Instance }?>, parent_STRONG: Instance) + local currentChildrenSet: { [Instance]: true } = {} -- cache of all children parented before update + local newChildrenSet: { [Instance]: true } = {} -- cache of all children parented after update + + setup(state, parent_STRONG, function(parent) + local newChildren = get(state) -- all (and only) children that should be parented after this update + if newChildren and type(newChildren) ~= "table" then + throw(`Cannot parent instance of type { type(newChildren) } `) + end + + if newChildren then + for _, child in next, newChildren do + newChildrenSet[child] = true -- record child set from this update + if not currentChildrenSet[child] then + child.Parent = parent -- if child wasn't already parented then parent it + else + currentChildrenSet[child] = nil -- remove child from cache if it was already in cache + end + end + end + + for child in next, currentChildrenSet do + child.Parent = nil -- unparent all children that weren't in the new children set + end + + table.clear(currentChildrenSet) -- clear cache, preserve capacity + currentChildrenSet, newChildrenSet = newChildrenSet, currentChildrenSet + end) +end + +local function bindEvent(state: State<() -> ()?>, instance_STRONG: Instance, event: RBXScriptSignal) + local current: RBXScriptConnection? = nil + setup(state, instance_STRONG, function(instance) + if current then + current:Disconnect() + current = nil + end + local listener = get(state) + if listener then + current = event:Connect(listener) + end + end) +end + +return { + property = bindProperty, + parent = bindParent, + children = bindChildren, + event = bindEvent +} diff --git a/src/create.lua b/src/create.lua new file mode 100644 index 0000000..b4fc1e1 --- /dev/null +++ b/src/create.lua @@ -0,0 +1,78 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/create.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then + script = (require :: any) "test/wrap-require" + Instance = require("test/mock").Instance +end + +local throw = require(script.Parent.throw) +local defaults = require(script.Parent.defaults) +local applyProperties = require(script.Parent.applyProperties) +local memoize = require(script.Parent.memoize) + +local function createInstance(className: string) + local success, instance: Instance = pcall(Instance.new, className :: any) + if success == false then throw(`invalid class name, could not create instance of class { className }`) end + + local default: { [string]: unknown }? = defaults[className] + if default then + for i, v in next, default do + (instance :: any)[i] = v + end + end + + return function(properties: { [any]: unknown }): Instance + return applyProperties(instance:Clone(), properties) + end +end; createInstance = memoize(createInstance) + +local function cloneInstance(instance: Instance) + return function(properties: { [any]: unknown }): Instance + local clone = instance:Clone() + if not clone then error("Attempt to clone a non-archivable instance", 3) end + return applyProperties(clone, properties) + end +end + +local function create(classNameOrInstance: string|Instance) + if type(classNameOrInstance) == "string" then + return createInstance(classNameOrInstance) + elseif typeof(classNameOrInstance) == "Instance" then + return cloneInstance(classNameOrInstance) + else + error("Bad argument #1, expected string or instance, got "..typeof(classNameOrInstance), 2) + end +end + +type Props = { [any]: any } +return (create :: any) :: +( (T & Instance) -> (Props) -> T ) & +( ("Folder") -> (Props) -> Folder ) & +( ("BillboardGui") -> (Props) -> BillboardGui ) & +( ("CanvasGroup") -> (Props) -> CanvasGroup ) & +( ("Frame") -> (Props) -> Frame ) & +( ("ImageButton") -> (Props) -> ImageButton ) & +( ("ImageLabel") -> (Props) -> ImageLabel ) & +( ("ScreenGui") -> (Props) -> ScreenGui ) & +( ("ScrollingFrame") -> (Props) -> ScrollingFrame ) & +( ("SurfaceGui") -> (Props) -> SurfaceGui ) & +( ("TextBox") -> (Props) -> TextBox ) & +( ("TextButton") -> (Props) -> TextButton ) & +( ("TextLabel") -> (Props) -> TextLabel ) & +( ("UIAspectRatioConstraint") -> (Props) -> UIAspectRatioConstraint ) & +( ("UICorner") -> (Props) -> UICorner ) & +( ("UIGradient") -> (Props) -> UIGradient ) & +( ("UIGridLayout") -> (Props) -> UIGridLayout ) & +( ("UIListLayout") -> (Props) -> UIListLayout ) & +( ("UIPadding") -> (Props) -> UIPadding ) & +( ("UIPageLayout") -> (Props) -> UIPageLayout ) & +( ("UIScale") -> (Props) -> UIScale ) & +( ("UISizeConstraint") -> (Props) -> UISizeConstraint ) & +( ("UIStroke") -> (Props) -> UIStroke ) & +( ("UITableLayout") -> (Props) -> UITableLayout ) & +( ("UITextSizeConstraint") -> (Props) -> UITextSizeConstraint ) & +( ("VideoFrame") -> (Props) -> VideoFrame ) & +( ("ViewportFrame") -> (Props) -> ViewportFrame ) & +( (string) -> (Props) -> Instance ) diff --git a/src/defaults.lua b/src/defaults.lua new file mode 100644 index 0000000..4727793 --- /dev/null +++ b/src/defaults.lua @@ -0,0 +1,112 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/defaults.lua +-------------------------------------------------------------------------------------------------------------- + +-- todo +local Enum = Enum +local Color3 = Color3 +local Vector3 = Vector3 + +if not game then + local mock = require "test/mock" + Enum = mock.Enum :: any + Color3 = mock.Color3 :: any + Vector3 = mock.Vector3 :: any +end + +return { + Part = { + Material = Enum.Material.SmoothPlastic, + Size = Vector3.new(1, 1, 1), + Anchored = true + }, + + BillboardGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling + }, + + CanvasGroup = nil, + + Frame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + }, + + ImageButton = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + AutoButtonColor = false + }, + + ImageLabel = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + }, + + ScreenGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling + }, + + ScrollingFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + ScrollBarImageColor3 = Color3.new(0, 0, 0) + }, + + SurfaceGui = { + ResetOnSpawn = false, + ZIndexBehavior = Enum.ZIndexBehavior.Sibling, + + PixelsPerStud = 50, + SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud + }, + + TextBox = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + ClearTextOnFocus = false, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + TextButton = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + AutoButtonColor = false, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + TextLabel = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0, + Font = Enum.Font.SourceSans, + Text = "", + TextColor3 = Color3.new(0, 0, 0) + }, + + -- UIComponent instances + + VideoFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + }, + + ViewportFrame = { + BackgroundColor3 = Color3.new(1, 1, 1), + BorderColor3 = Color3.new(0, 0, 0), + BorderSizePixel = 0 + } +} diff --git a/src/derive.lua b/src/derive.lua new file mode 100644 index 0000000..1a0269a --- /dev/null +++ b/src/derive.lua @@ -0,0 +1,32 @@ +------------------------------------------------------------------------------------------ +-- vide/derive.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +type Unwrapper = graph.Unwrapper +local create = graph.create +local captureAndLink = graph.captureAndLink + +local function derive(deriveValue: (Unwrapper) -> T, cleanup: (T) -> ()?): State + local node = create((nil :: any) :: T) + + if cleanup then + local fn = deriveValue + local last: T? = nil + deriveValue = function(from) + if last ~= nil then cleanup(last) end + last = fn(from) + return last :: T + end + end + + local value: T = captureAndLink(node, deriveValue) + node.cache = value + + return node :: State +end + +return derive diff --git a/src/each.lua b/src/each.lua new file mode 100644 index 0000000..1872ec7 --- /dev/null +++ b/src/each.lua @@ -0,0 +1,24 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/each.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +type Node = graph.Node +local create = graph.create +local get = graph.get +local wrapped = graph.wrapped +local link = graph.link + +type Map = { [K]: V } + +local function each(input: State>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) + +end + +return (each :: any) :: ( (input: number, transform: (number) -> V) -> Map ) & + ( (input: Map, transform: (K, VI) -> VO) -> Map ) & + ( (input: State>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) + -> State> ) diff --git a/src/flags.lua b/src/flags.lua new file mode 100644 index 0000000..34d915e --- /dev/null +++ b/src/flags.lua @@ -0,0 +1,5 @@ +-------------------------------------------------------------------------------------------------------------------------------- +-- vide/flags.lua +-------------------------------------------------------------------------------------------------------------------------------- + +return { strict = false } diff --git a/src/graph.lua b/src/graph.lua new file mode 100644 index 0000000..3238bfc --- /dev/null +++ b/src/graph.lua @@ -0,0 +1,231 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/graph.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local flags = require(script.Parent.flags) + +export type State = typeof(setmetatable( + {} :: { + cache: T, + updated: boolean, + derive: (any) -> T, + effects: { [(unknown) -> ()]: unknown } | false, -- weak values + children: { State } | false -- weak values + }, {} :: { + __concat: (any, any) -> any, + __add: (any, any) -> any, + __sub: (any, any) -> any, + __mul: (any, any) -> any, + __div: (any, any) -> any, + --__pow + --__mod + --__unm + --__eq: (unknown, unknown) -> State + --__lt + --__le + } +)) + +export type MaybeState = State | T +export type Unwrapper = (T) -> T + +local WEAK_VALUES_RESIZABLE = { __mode = "vs" } +local EVALUATION_ERR = "Error while evaluating state:\n\n" + +local State = {} + +local function wrapped(value: any): boolean + return getmetatable(value) == State +end + +local unwrap: (T) -> T; + +local checkForYield do + local t = { __mode = "kv" } + setmetatable(t, t) + + checkForYield = function(fn: (Unwrapper) -> ()) + t.__unm = function() + fn(unwrap) + end + local ok, err = pcall(function() + return -t :: any + end) + + if not ok then + if err == "attempt to yield across metamethod/C-call boundary" or err == "thread is not yieldable" then + error(EVALUATION_ERR .. "Cannot yield when deriving state in watcher", 3) + else + error(EVALUATION_ERR..err, 3) + end + end + end +end + +local function setEffect(state: State, fn: (T) -> (), key: T) + if not state.effects then + state.effects = setmetatable({ [fn] = key }, WEAK_VALUES_RESIZABLE) :: any + else + state.effects[fn :: () -> ()] = key + end +end + +local function runEffects(state: State) + if state.effects then + for effect, key in next, state.effects do + if flags.strict then effect(key) end + effect(key) + end + end +end + +-- retrieves a state's cached value +-- recalculates value if an ancestor was updated +local function get(state: State): T + if state.updated then + state.updated = false + + if flags.strict then checkForYield(state.derive) end + + local ok, result: T|string? = pcall(state.derive, unwrap); if ok then + rawset(state :: any, "cache", result :: T) + else error(EVALUATION_ERR .. result :: string, 2) end + end + + return state.cache +end + +-- utility function for retrieving a value from state and allowing passthrough of non-state +unwrap = function(value: MaybeState): T + if wrapped(value) then + return get(value :: State) + else + return value :: T + end +end + +local function addChild(parent: State, child: State) + if parent.children then + table.insert(parent.children, child) + else + parent.children = setmetatable({ child }, WEAK_VALUES_RESIZABLE) :: any + end +end + +-- marks all state descendants for recalculation and runs effects +local function update(state: State) + runEffects(state) + if state.children then + for _, child in state.children do + if not child.updated then + child.updated = true + update(child) + end + end + end +end + +-- sets a state's cached value and updates all descendants +local function set(state: State, value: T) + state.cache = value + update(state) +end + +-- links two states as parent-child +local function link(parent: State, child: State, derive: () -> unknown) + child.derive = derive + addChild(parent, child) +end + +-- detect what states were referenced in the given callback and returns them in an array +local function capture(callback: (Unwrapper) -> T): ({ State }, T) + if flags.strict then checkForYield(callback) end + + local states = table.create(2) + + local ok: boolean, result: T|string = pcall(callback, function(value: MaybeState): T + if wrapped(value) then + table.insert(states, value :: State) + return get(value :: State) + else + return value :: T + end + end) + + if not ok then error("Error while detecting watcher: " .. result :: string, 2) end + + return states, result :: T +end + +-- captures and links any detected states +local function captureAndLink(child: State, callback: (Unwrapper) -> T): T + local states, value = capture(callback) + + child.derive = callback + for _, parent: State in next, states do + addChild(parent, child) + end + + return value :: T +end + +local create: (value: T) -> State + +-- factory function for creating operator overloads for shorthands to derive state +local function overload(op: (unknown, unknown) -> unknown): (any, any) -> any + return function(a: MaybeState, b: MaybeState): State + local derived: State = create(nil :: any) + + local aIsState = wrapped(a) + local bIsState = wrapped(b) + + if aIsState and bIsState then + local function derive() return op(get(a :: State), get(b :: State)) end + link(a :: State, derived, derive) + link(b :: State, derived, derive) + elseif aIsState then + link(a :: State, derived, function() return op(get(a :: State), b) end) + else--if bIsState then + link(b :: State, derived, function() return op(a, get(b :: State)) end) + end + + derived.updated = true + return derived + end +end + +function State.__index(_, index) + if index == "cache" then return nil end -- todo: better solution + error("attempt to index state", 2) +end + +State.__concat = overload(function(a: any, b: any) return tostring(a) .. tostring(b) end) +State.__add = overload(function(a: any, b: any) return a + b end) +State.__sub = overload(function(a: any, b: any) return a - b end) +State.__mul = overload(function(a: any, b: any) return a * b end) +State.__div = overload(function(a: any, b: any) return a / b end) +--State.__eq = overload(function(a: any, b: any) return a == b end) + +function create(value: T): State + return setmetatable({ + cache = value, + updated = false, + derive = function() return nil end :: any, + effects = false :: false, + children = false :: false + }, State) +end + +return table.freeze { + setEffect = setEffect, + get = get, + set = set, + unwrap = unwrap, + link = link, + capture = capture, + captureAndLink = captureAndLink, + wrapped = wrapped, + create = create, +} diff --git a/src/init.lua b/src/init.lua new file mode 100644 index 0000000..a7b304a --- /dev/null +++ b/src/init.lua @@ -0,0 +1,84 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide.lua +-- v0.1.0 +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local create = require(script.create) +local apply = require(script.apply) +local wrap = require(script.wrap) +local watch = require(script.watch) +local derive = require(script.derive) +local map = require(script.map) + +local unwrap = require(script.unwrap) +local wrapped = require(script.wrapped) + +local Layout = require(script.Layout) +local Children = require(script.Children) +local Event = require(script.Event) +local Changed = require(script.Change) +local Created = require(script.Created) + +local spring, updateSprings = require(script.spring)() + +local flags = require(script.flags) + +type Map = { [K]: V } +type Unwrapper = (T) -> T +type Setter = ( (new: T, force: true?) -> T ) & ( (update: (old: T) -> T, force: true?) -> T ) + +local vide = { + -- core + create = create, + apply = apply, + wrap = (wrap :: any) :: (value: T?) -> (T, Setter), + derive = (derive :: any) :: (deriver: (from: (U) -> U) -> T, cleanup: (T) -> ()?) -> T, + map = (map :: any) :: ( (input: number, transform: (number) -> V, cleanup: (V) -> ()?) -> Map ) & ( (input: Map, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) -> Map ), + watch = watch :: ((Unwrapper) -> ()) -> () -> (), + + -- util + unwrap = unwrap, + wrapped = wrapped, + + -- animations + spring = (spring :: any) :: (input: T, period: number, damping: number?) -> T, + + -- symbols + Event = Event, + Changed = Changed, + Layout = Layout, + Children = Children, + Created = Created, + + -- flags + strict = (nil :: any) :: boolean, + + -- test + step = function(dt: number) + updateSprings(dt) + end +} + +setmetatable(vide :: any, { + __index = function(_, index: unknown) + error(string.format("\"%s\" is not a valid member of vide", tostring(index)), 2) + end, + + __newindex = function(_, index: unknown, value: unknown) + if index == "strict" then + flags.strict = if type(value) == "boolean" then value else error("strict must be a boolean", 2) + else + error(string.format("\"%s\" is not a valid member of vide", tostring(index)), 2) + end + end +}) + +if game then + game:GetService("RunService").Heartbeat:Connect(function(dt: number) + task.defer(vide.step, dt) + end) +end + +return vide diff --git a/src/map.lua b/src/map.lua new file mode 100644 index 0000000..b805a23 --- /dev/null +++ b/src/map.lua @@ -0,0 +1,76 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/map.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +type MaybeState = graph.MaybeState +local create = graph.create +local get = graph.get +local wrapped = graph.wrapped +local link = graph.link + +type Map = { [K]: V } + +local function map(input: unknown, transform: (K, VI) -> VO, cleanup: (VO) -> ()?): (MaybeState>) + if type(input) == "number" then + local output: Map = table.create(input) :: {} + + for i = 1, input do + local v = transform(i :: any, nil :: any) + output[i :: any] = v + end + + return output + elseif wrapped(input) then + local lastInput: Map = {} + local lastOutput: Map = {} + local output = create(lastOutput) + + local function derive() + local newInput = get(input :: State>) + if type(newInput) ~= "table" then error("Attempt to run map on a non-table state", 0) end + + local lastInputClone = table.clone(lastInput) + + for k, vi in next, newInput do + if vi ~= lastInputClone[k] then + local vo = transform(k, vi) + if cleanup and lastOutput[k] then cleanup(lastOutput[k]) end + lastInput[k] = vi + lastOutput[k] = vo + end + lastInputClone[k] = nil + end + + for k, v in next, lastInputClone do + lastInput[k] = nil + if cleanup and lastOutput[k] then cleanup(lastOutput[k]) end + lastOutput[k] = nil + end + + return table.clone(lastOutput) + end + + link(input :: State>, output, derive) + output.updated = true + + return output + elseif type(input) == "table" then + local output: Map = table.create(#input :: any) :: {} + + for k, vi in input :: Map do + local vo = transform(k, vi) + output[k] = vo + end + + return output + else error(string.format("Invalid type arg #1, expected number or table or state (got %s)", tostring(input)), 2) end +end + +return (map :: any) :: ( (input: number, transform: (number) -> V) -> Map ) & + ( (input: Map, transform: (K, VI) -> VO) -> Map ) & + ( (input: State>, transform: (K, VI) -> VO, cleanup: (VO) -> ()?) + -> State> ) diff --git a/src/memoize.lua b/src/memoize.lua new file mode 100644 index 0000000..a1cb8b7 --- /dev/null +++ b/src/memoize.lua @@ -0,0 +1,21 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/memoize.lua +-------------------------------------------------------------------------------------------------------------- + +local function memoize(f: (X) -> Y): ((X) -> Y, { [X]: Y }) + local cache: { [X]: Y } = {} + + return function(x: X): Y + local y: Y? = cache[x] + + if not y then + y = f(x) + cache[x] = y + end + + return y :: Y + end, cache +end + +return memoize + diff --git a/src/spring.lua b/src/spring.lua new file mode 100644 index 0000000..0d7ce38 --- /dev/null +++ b/src/spring.lua @@ -0,0 +1,169 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/spring.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +--[[ + Spring animation library adapted from RDL::spring v1.0 + + Supported datatypes: + *number + !bool + *CFrame + ?Rect + *Color3 + *UDim + *UDim2 + *Vector2 + !Vector2int16 + *Vector3 + !Vector3int16 + !EnumItem +]] + +local throw = require(script.Parent.throw) + +local graph = require(script.Parent.graph) +type State = graph.State +type MaybeState = graph.MaybeState +local create = graph.create +local get = graph.get +local set = graph.set +local unwrap = graph.unwrap +local wrapped = graph.wrapped + +type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 + +type SpringData = { + Alpha: number; + Duration: number; + Period: number; + Damping: number; + Velocity: number; + InitialVelocity: number; + Initial: T; + Target: T; + Input: State; +} + +type Lerp = (initial: T, target: T, alpha: number) -> T + +local function solve(T: number, z: number, u: number, t: number): number -- alpha + local wn = 2*math.pi / T + local wd = wn * math.sqrt(1 - z^2) + local a = z * wn + + local s = math.exp(-a*t) * math.cos(wd*t) + local v = (u/wn) * math.exp(-a*t) * math.sin(wn*t) + return (1-s) + v +end + +local lerpable: { [string]: Lerp } = { + number = function(v1, v2, a) + return v1 + (v2 - v1)*a + end :: Lerp, + + CFrame = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Color3 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + UDim = function(v1, v2, a) + return UDim.new( + v1.Scale + (v2.Scale - v1.Scale)*a, + v1.Offset + (v2.Offset - v1.Offset)*a + ) + end :: Lerp, + + UDim2 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Vector2 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, + + Vector3 = function(v1, v2, a) + return v1:Lerp(v2, a) + end :: Lerp, +} + +local activeSprings: { [State]: SpringData } = {} +setmetatable(activeSprings, { __mode = "ks" }) + +local function spring(input: MaybeState, period: number, damping: number?): State + local initial = unwrap(input) :: T + local output = create(initial) + + local springData: SpringData = { + Alpha = 0, + Duration = 0, + Period = period, + Damping = damping or 1, + Velocity = 0, + InitialVelocity = 0, + Initial = initial, + Target = initial, + Input = input :: any, + LastInput = initial, + LastOutput = initial + } + + activeSprings[output] = springData + + return output +end + +local function updateSprings(dt: number) + for node, data in next, activeSprings do + local currentTarget = get(data.Input) + if currentTarget ~= data.Target then + data.Target = currentTarget + data.Initial = get(node) + data.Target = get(data.Input) + data.Alpha = 0 + data.Duration = 0 + data.InitialVelocity = data.Velocity + end + + local initial: Animatable = data.Initial + local target: Animatable = data.Target + local targetType: string = typeof(target) + + if targetType ~= typeof(initial) then + activeSprings[node] = nil + warn(string.format( + "Mismatched state value types, cancelling state update (initial value: %s, target value: %s)", + typeof(initial), + targetType + )) + throw(`Cannot tween type { typeof(initial) } and { targetType }`) + continue + end + + local lerp: Lerp = lerpable[targetType] + + if lerp == nil then + activeSprings[node] = nil + throw(`Cannot animate type { targetType }`) + continue + end + + local newTime = data.Duration + dt + local newAlpha = solve(data.Period, data.Damping, data.InitialVelocity, newTime) + + data.Velocity = -(newAlpha - data.Alpha)/dt + data.Alpha = newAlpha + data.Duration = newTime + + local value = lerp(initial, target, newAlpha) + + set(node, value) + end +end + +return function() return spring, updateSprings end diff --git a/src/throw.lua b/src/throw.lua new file mode 100644 index 0000000..8e9fba0 --- /dev/null +++ b/src/throw.lua @@ -0,0 +1,15 @@ +------------------------------------------------------------------------------------------ +-- vide/throw.lua +------------------------------------------------------------------------------------------ + +local function throw(msg: string) + local stack = 1 + + while debug.info(stack, "s") == debug.info(1, "s") do + stack += 1 + end + + error(msg, stack) +end + +return throw diff --git a/src/unwrap.lua b/src/unwrap.lua new file mode 100644 index 0000000..490e53a --- /dev/null +++ b/src/unwrap.lua @@ -0,0 +1,9 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/unwrap.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) + +return graph.unwrap diff --git a/src/watch.lua b/src/watch.lua new file mode 100644 index 0000000..2ab094e --- /dev/null +++ b/src/watch.lua @@ -0,0 +1,36 @@ +-------------------------------------------------------------------------------------------------------------- +-- vide/watch.lua +-------------------------------------------------------------------------------------------------------------- + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +type Unwrapper = graph.Unwrapper +local setEffect = graph.setEffect +local capture = graph.capture +local unwrap = graph.unwrap + +local function watch(effect: (Unwrapper) -> ()): () -> () + local states, cleanup = capture(effect :: () -> (() -> ()?)) + + local function fn() + if cleanup then cleanup(); cleanup = nil end + cleanup = effect(unwrap) + end + + for _, state in next, states do + setEffect(state, fn, true) + end + + local function unwatch() + for _, state in next, states do + setEffect(state, fn, nil) + end + if cleanup then cleanup(); cleanup = nil end + end + + return unwatch +end + +return watch diff --git a/src/wrap.lua b/src/wrap.lua new file mode 100644 index 0000000..5eb41dc --- /dev/null +++ b/src/wrap.lua @@ -0,0 +1,42 @@ +------------------------------------------------------------------------------------------ +-- vide/wrap.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) +type State = graph.State +type MaybeState = graph.MaybeState +local create = graph.create +local get = graph.get +local set = graph.set +local wrapped = graph.wrapped + +local throw = require(script.Parent.throw) +local flags = require(script.Parent.flags) + +type Setter = (value: MaybeState | (MaybeState) -> MaybeState, force: boolean?) -> T + +local function wrap(value: MaybeState?): (State, Setter) + local state = create(if wrapped(value) then get(value :: State) else value :: T) + + local function setter(vi: MaybeState | (MaybeState) -> MaybeState, force: boolean?): T + if type(vi) == "function" then + vi = vi(get(state)) + end + + local v = if wrapped(vi) then get(vi :: State) else vi :: T + + if v ~= state.cache or force then + set(state, v) + elseif flags.strict and type(v) == "table" then + throw("attempt to set same table object") + end + + return v + end + + return state, setter +end + +return wrap diff --git a/src/wrapped.lua b/src/wrapped.lua new file mode 100644 index 0000000..4a68ee3 --- /dev/null +++ b/src/wrapped.lua @@ -0,0 +1,9 @@ +------------------------------------------------------------------------------------------ +-- vide/wrapped.lua +------------------------------------------------------------------------------------------ + +if not game then script = (require :: any) "test/wrap-require" end + +local graph = require(script.Parent.graph) + +return graph.wrapped diff --git a/test/benchmark.luau b/test/benchmark.luau new file mode 100644 index 0000000..d771ba5 --- /dev/null +++ b/test/benchmark.luau @@ -0,0 +1,155 @@ +------------------------------------------------------------------------------------------ +-- benchmark.lua +------------------------------------------------------------------------------------------ + +local BENCH, START = require("test/testkit").getBenchmarkTools() + +local vide = require "src/init" +type State = vide.State + +local function gc(n: number?) + for i = 1, n or 3 do + (collectgarbage :: any)("collect") + end +end + +local N = 1e5 + +gc() + +BENCH("Create state", function() + local cache = table.create(N) + local wrap = vide.wrap + + for i = 1, START(N) do + cache[i] = wrap(1) + end +end) + +gc() + +BENCH("Get state value", function() + local state = vide.wrap(1) + local unwrap = vide.unwrap + + for i = 1, START(N) do + unwrap(state) + end +end) + +gc() + +BENCH("Set state value", function() + local _, set = vide.wrap(1) + + for i = 1, START(N) do + set(i) + end +end) + +gc() + +BENCH("Derive state", function() + local cache = table.create(N) + local state = vide.wrap(1) + local derive = vide.derive + + for i = 1, START(N) do + cache[i] = derive(function(from) + return from(state) + end) + end +end) + +gc() + +BENCH("Derive state (2)", function() + local cache = table.create(N) + local state = vide.wrap(1) + local state2 = vide.wrap(2) + local derive = vide.derive + + for i = 1, START(N) do + cache[i] = derive(function(from) + return from(state) + from(state2) + end) + end +end) + +gc() + +BENCH("Derive state (shorthand)", function() + local cache = table.create(N) + local state = vide.wrap(1) + + for i = 1, START(N) do + cache[i] = state + 1 + end +end) + +gc() + +BENCH("Derive state (shorthand 2)", function() + local cache = table.create(N) + local state = vide.wrap(1) + local state2 = vide.wrap(2) + + for i = 1, START(N) do + cache[i] = state + state2 + end +end) + +gc() + +BENCH("Derived state update", function() + local stateA, setA = vide.wrap(1) + local stateB = vide.derive(function(from) return from(stateA) end) + local unwrap = vide.unwrap + + for i = 1, START(N) do + setA(i) + unwrap(stateB) + end +end) + +gc() + +BENCH("Derived state update (shorthand)", function() + local stateA, setA = vide.wrap(1) + local stateB = stateA + 1 + local unwrap = vide.unwrap + + for i = 1, START(N) do + setA(i) + unwrap(stateB) + end +end) + +gc() + +BENCH("Apply single property", function() + local instance = vide.create("Frame") {} + local apply = vide.apply + + for i = 1, START(N) do + apply(instance) { + Name = i + } + end +end) + +gc() + +BENCH("Bind state", function() + local instance = vide.create("Frame") {} + local state = vide.wrap(1) + local apply = vide.apply + + for i = 1, START(N) do + apply(instance) { + Name = state + } + end +end) + +return nil diff --git a/test/goodsignal.lua b/test/goodsignal.lua new file mode 100644 index 0000000..26aae6d --- /dev/null +++ b/test/goodsignal.lua @@ -0,0 +1,187 @@ +-- wrapped task library for use in pure luau +local task = { spawn = function(thread, ...) + local ok, err = coroutine.resume(thread, ...) + if not ok then error(err, 3) end +end } + +---------------------------------------------------------------------------------------------------- +-- Batched Yield-Safe Signal Implementation -- +-- This is a Signal class which has effectively identical behavior to a -- +-- normal RBXScriptSignal, with the only difference being a couple extra -- +-- stack frames at the bottom of the stack trace when an error is thrown. -- +-- This implementation caches runner coroutines, so the ability to yield in -- +-- the signal handlers comes at minimal extra cost over a naive signal -- +-- implementation that either always or never spawns a thread. -- +-- -- +-- API: -- +-- local Signal = require(THIS MODULE) -- +-- local sig = Signal.new() -- +-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- +-- sig:Fire(arg1, arg2, ...) -- +-- connection:Disconnect() -- +-- sig:DisconnectAll() -- +-- local arg1, arg2, ... = sig:Wait() -- +-- -- +-- Licence: -- +-- Licenced under the MIT licence. -- +-- -- +-- Authors: -- +-- stravant - July 31st, 2021 - Created the file. -- +---------------------------------------------------------------------------------------------------- + +-- The currently idle thread to run the next handler on +local freeRunnerThread = nil + +-- Function which acquires the currently idle handler runner thread, runs the +-- function fn on it, and then releases the thread, returning it to being the +-- currently idle one. +-- If there was a currently idle runner thread already, that's okay, that old +-- one will just get thrown and eventually GCed. +local function acquireRunnerThreadAndCallEventHandler(fn, ...) + local acquiredRunnerThread = freeRunnerThread + freeRunnerThread = nil + fn(...) + -- The handler finished running, this runner thread is free again. + freeRunnerThread = acquiredRunnerThread +end + +-- Coroutine runner that we create coroutines of. The coroutine can be +-- repeatedly resumed with functions to run followed by the argument to run +-- them with. +local function runEventHandlerInFreeThread() + -- Note: We cannot use the initial set of arguments passed to + -- runEventHandlerInFreeThread for a call to the handler, because those + -- arguments would stay on the stack for the duration of the thread's + -- existence, temporarily leaking references. Without access to raw bytecode + -- there's no way for us to clear the "..." references from the stack. + while true do + acquireRunnerThreadAndCallEventHandler(coroutine.yield()) + end +end + +-- Connection class +local Connection = {} +Connection.__index = Connection + +function Connection.new(signal, fn) + return setmetatable({ + _connected = true, + _signal = signal, + _fn = fn, + _next = false, + }, Connection) +end + +function Connection:Disconnect() + self._connected = false + + -- Unhook the node, but DON'T clear it. That way any fire calls that are + -- currently sitting on this node will be able to iterate forwards off of + -- it, but any subsequent fire calls will not hit it, and it will be GCed + -- when no more fire calls are sitting on it. + if self._signal._handlerListHead == self then + self._signal._handlerListHead = self._next + else + local prev = self._signal._handlerListHead + while prev and prev._next ~= self do + prev = prev._next + end + if prev then + prev._next = self._next + end + end +end + +-- Make Connection strict +setmetatable(Connection, { + __index = function(tb, key) + error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(tb, key, value) + error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) + end +}) + +-- Signal class +local Signal = {} +Signal.__index = Signal + +function Signal.new() + return setmetatable({ + _handlerListHead = false, + }, Signal) +end + +function Signal:Connect(fn) + if type(fn) ~= "function" then error(`attempt to connect non function (got { type(fn) })`, 2) end + local connection = Connection.new(self, fn) + if self._handlerListHead then + connection._next = self._handlerListHead + self._handlerListHead = connection + else + self._handlerListHead = connection + end + return connection +end + +-- Disconnect all handlers. Since we use a linked list it suffices to clear the +-- reference to the head handler. +function Signal:DisconnectAll() + self._handlerListHead = false +end + +-- Signal:Fire(...) implemented by running the handler functions on the +-- coRunnerThread, and any time the resulting thread yielded without returning +-- to us, that means that it yielded to the Roblox scheduler and has been taken +-- over by Roblox scheduling, meaning we have to make a new coroutine runner. +function Signal:Fire(...) + local item = self._handlerListHead + while item do + if item._connected then + if not freeRunnerThread then + freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) + -- Get the freeRunnerThread to the first yield + coroutine.resume(freeRunnerThread) + end + task.spawn(freeRunnerThread, item._fn, ...) + end + item = item._next + end +end + +-- Implement Signal:Wait() in terms of a temporary connection using +-- a Signal:Connect() which disconnects itself. +function Signal:Wait() + local waitingCoroutine = coroutine.running() + local cn; + cn = self:Connect(function(...) + cn:Disconnect() + task.spawn(waitingCoroutine, ...) + end) + return coroutine.yield() +end + +-- Implement Signal:Once() in terms of a connection which disconnects +-- itself before running the handler. +function Signal:Once(fn) + local cn; + cn = self:Connect(function(...) + if cn._connected then + cn:Disconnect() + end + fn(...) + end) + return cn +end + +-- Make signal strict +setmetatable(Signal, { + __index = function(tb, key) + error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(tb, key, value) + error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) + end +}) + +return Signal diff --git a/test/mock.luau b/test/mock.luau new file mode 100644 index 0000000..8971674 --- /dev/null +++ b/test/mock.luau @@ -0,0 +1,230 @@ +local Instance = {} do + local Signal = require "test/goodsignal" + + type userdata = { __USERDATA__: true } + + type Proxy = { + _Userdata: userdata, + _Data: Data, + __index: any, + __newindex: any + } + + type Data = { + Name: string, + Parent: Data?, + Children: { Data }, + Changed: { [string]: RBXScriptSignal & { Fire: any } }, + Table: { [string]: unknown }, + Destroying: RBXScriptSignal, + ClassName: string, + Type: "Instance" + } + + --[[ + attempt to mimic roblox engine's method of userdata proxy to actual instance data + proxy can gc independantly of actual instance data + proxy prevents gc of actual instance data + luau code never has direct access to actual instance data, only to proxy + ]] + local proxies = {} :: { [Data]: userdata? } + setmetatable(proxies :: any, { __mode = "v" }) + + local function getdata(userdata: userdata): Data + local function getproxy(userdata: userdata): Proxy + return getmetatable(userdata :: any) + end + + return getproxy(userdata)._Data + end + + local function isInstance(value: unknown): boolean + local mt = getmetatable(value :: any) + return mt and mt._Data and mt._Data.Type == "Instance" + end + + local methods = {} + + local function __index(userdata: userdata, property: string): () + local data = getdata(userdata) + return if methods[property] then methods[property] + elseif property == "Name" then data.Name + elseif property == "Parent" then data.Parent + elseif property == "Destroying" then data.Destroying + else data.Table[property] + end + + local function __newindex(userdata: userdata, property: string, value: unknown) + local data = getdata(userdata) + if property == "Name" then + data.Name = value :: string + elseif property == "Parent" then + assert(value == nil or isInstance(value), "attempt to set non-instance as parent") + local parent = data.Parent + if parent then + data.Parent = nil + table.remove(parent.Children, table.find(parent.Children, data)) + end + if value then + data.Parent = getdata(value :: userdata) + table.insert(getdata(value :: userdata).Children, data) + end + else + data.Table[property] = value + end + + if data.Changed[property] then + data.Changed[property]:Fire() + end + end + + local function getuserdata(data: Data): userdata + return proxies[data] or (function() + local userdata = newproxy(true) + local proxy = getmetatable(userdata) + proxy._Userdata = userdata + proxy._Data = data + proxy.__index = __index + proxy.__newindex = __newindex + proxies[data] = userdata + return userdata + end)() + end + + function Instance.new(class: string): Instance + local data = { + Name = "UNNAMED", + Parent = nil, + Children = {}, + Changed = {}, + Table = {}, + ClassName = class, + Destroying = Signal.new() :: any, + Type = "Instance" :: "Instance" + } + + return getuserdata(data) :: any + end + + function Instance.isInstance(value: unknown): boolean + return isInstance(value) + end + + function methods.Clone(userdata: userdata): userdata + local data = getdata(userdata) + local clone_userdata = (Instance.new("") :: any) :: userdata + local clone_data = getdata(clone_userdata) + + table.clear(clone_data) + for i, v in next, data do + clone_data[i] = type(v) == "table" and table.clone(v) or v + end + return clone_userdata + end + + function methods.FindFirstChild(userdata: userdata, target: string): userdata? + local data = getdata(userdata) + for _, child in data.Children do + if child.Name == target then + return getuserdata(child) + end + end + return nil + end + + function methods.GetChildren(userdata: userdata): { userdata } + local children = getdata(userdata).Children + local userdatas = table.create(#children) + + for i, child in next, children do + userdatas[i] = getuserdata(child) + end + + return userdatas + end + + function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal + local data = getdata(userdata) + if not data.Changed[property] then + data.Changed[property] = Signal.new() :: any + end + return data.Changed[property] + end + + function methods.Destroy(userdata: userdata) + local data = getdata(userdata); + (data.Destroying :: any):Fire() + data.Parent = nil + if data.Changed["Parent"] then + data.Changed["Parent"]:Fire() + end + end +end + +local Color3 = {} do + function Color3.new(r, g, b): Color3 + return setmetatable({ r = r, g = g, b = b}, Color3) :: any + end + + function Color3.__eq(a, b) + return a.r == b.r and a.g == b.g and a.b == b.b + end +end + +local Vector3 = {} do + function Vector3.new(x, y, z): Vector3 + return setmetatable({ x = x, y = y, z = z}, Vector3) :: any + end + + function Vector3.__eq(a, b) + return a.x == b.x and a.y == b.y and a.z == b.z + end +end + +local Vector2 = {} do + function Vector2.new(x, y): Vector2 + return setmetatable({ x = x, y = y }, Vector2) :: any + end + + function Vector2.__eq(a, b) + return a.x == b.x and a.y == b.y + end +end + +local UDim2 = {} do + function UDim2.fromScale(x, y): UDim2 + return setmetatable({ x = { scale = x, offset = 0 }, y = { scale = y, offset = 0 } }, UDim2) :: any + end + + function UDim2.__eq(a, b) + return a.x.scale == b.x.scale and + b.x.offset == b.x.offset and + a.y.scale == b.y.scale and + a.y.offset == b.y.offset + end +end + +local Enum = {} :: any do + setmetatable(Enum, { __index = function(self, index) + local v = setmetatable({}, { __index = function(self, index) + self[index] = true + return true + end}) + self[index] = v + return v + end}) +end + +local function typeof(v) + return Instance.isInstance(v) and "Instance" or type(v) +end + +return { + Instance = Instance, + Color3 = Color3, + Vector3 = Vector3, + Vector2 = Vector2, + UDim2 = UDim2, + Enum = Enum, + typeof = typeof +} diff --git a/test/syntax.luau b/test/syntax.luau new file mode 100644 index 0000000..fd1168a --- /dev/null +++ b/test/syntax.luau @@ -0,0 +1,72 @@ +local vide = require "src/init" +local wrap = vide.wrap +local derive = vide.derive +local map = vide.map +local create = vide.create +local unwrap = vide.unwrap +local Event = vide.Event +local Layout = vide.Layout + +do + local count = wrap(0) + local count2 = derive(function(from) + local c = from(count) * unwrap(count) + return c ^ 2 + end) + local x = count2.value + + local count2b = count + 1 + local xb = unwrap(count2b) +end + +local count = wrap(0) +local v = count.value + +do + local t = map(1, function(i) + return "" + end) + + local t = map({ true }, function(i, v) + return 1 + end) + + local data = wrap { "" } + + local t = map(data, function(i, v) + return 1 + end) +end + + + +create("Frame") { + +} + +local Value, Computed, peek, OnEvent = nil :: any, nil :: any, nil :: any, nil :: any +local New = nil :: any + +local function Counter(props) + local count, set = wrap(0) + + return create("TextLabel") { + Text = "Count: " .. count, + + [Layout] = props[Layout], + + [Event.Activated] = function() + set(count + 1) + end + } +end + +local function Frame(props) + return create("Frame") { + Position = props.Position + } +end + +Frame { Position = UDim2.fromScale(0.5, 0.5) } + +Frame { Position = positionState } diff --git a/test/testkit.luau b/test/testkit.luau new file mode 100644 index 0000000..144f908 --- /dev/null +++ b/test/testkit.luau @@ -0,0 +1,321 @@ +------------------------------------------------------------------------------------------ +-- testkit.luau +-- v0.2.0 +------------------------------------------------------------------------------------------ + +--[[ + +EXAMPLE USAGE: + +local testkit = require "path-to-testkit" + +local TEST, CASE, CHECK = testkit.getUnitTestTools() + +TEST("test name", function() + do CASE "A" + CHECK(condition) + end +end) + +local BENCH, START = testkit.getBenchmarkTools() + +BENCH("benchmark name", function() + local x = 0 + + for i = 1, START(1e6) do + x += 1 + end +end) +]] + +------------------------------------------------------------------------------------------ +-- Unit Testing +------------------------------------------------------------------------------------------ + +type Test = { + name: string, + activeCase: Case?, + cases: { Case }, + duration: number, + error: string? +} + +type Case = { + name: string, + result: number, + line: number? +} + +local PASS = 1 +local FAIL = 2 +local NONE = 3 +local ERROR = 4 + +local activeTest: Test? +local tests: { Test } = {} + +local function outputTestResults(test: Test) + print("\27[1;4m"..test.name.."\27[0m") + for _, case in test.cases do + print( + "[" .. + (if case.result == PASS then + "\27[32;1mPASS\27[0m" + elseif case.result == FAIL then + "\27[31;1mFAIL:"..assert(case.line).."\27[0m" + elseif case.result == NONE then + "\27[33;1mNONE\27[0m" + else + "\27[41;1;30mERROR\27[0m") + .. "] " .. case.name + ) + end + + if test.error then + print("\27[31;1;30merror: " .. test.error .. "\27[0m") + end + + print "" +end + +local function CASE(name: string) + assert(activeTest, "no active test") + + local case: Case = { + name = name, + result = NONE + } + + activeTest.activeCase = case + table.insert(activeTest.cases, case) +end + +local function CHECK(value: any): boolean + assert(activeTest, "no active test") + local activeCase = activeTest.activeCase + + if not activeCase then + CASE "" + activeCase = activeTest.activeCase + end; assert(activeCase, "no active case") + + local result = value and PASS or FAIL + + if activeCase.result == NONE or activeCase.result == PASS then + activeCase.result = result + activeCase.line = debug.info(2, "l") + end + + return result == PASS +end + +local function TEST(name: string, fn: () -> ()) + assert(not activeTest, "new test was started while a test was in progress") + local test: Test = { + name = name, + cases = {}, + duration = 0 + } + + activeTest = test + table.insert(tests, test) + + local start = os.clock() + local msg: string? + local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end) + test.duration = os.clock() - start + + if not test.activeCase then CASE "" end + assert(test.activeCase, "no active case") + + if not success then + test.activeCase.result = ERROR + test.error = msg + end + + activeTest = nil + outputTestResults(test) +end + +local function FINISH(): boolean + local success = true + local totalCases = 0 + local passedCases = 0 + local duration = 0 + + for _, test in tests do + duration += test.duration + for _, case in test.cases do + totalCases += 1 + if case.result == PASS or case.result == NONE then + passedCases += 1 + else + success = false + end + end + end + + print(string.format("%d/%d test cases passed in %.3f ms.", passedCases, totalCases, duration*1e3)) + + local fails = totalCases - passedCases + + print(string.format("\27[%d;1;30m%d fail%s\27[0m", fails > 0 and 41 or 42, fails, fails == 1 and "" or "s")) + + return success, table.clear(tests) +end + +------------------------------------------------------------------------------------------ +-- Benchmarking +------------------------------------------------------------------------------------------ + +type Bench = { + timeStart: number?, + memStart: number?, + iterations: number? +} + +local activeBench: Bench? = nil + +function START(iter: number?): number + local n = iter or 1 + if n < 1 then error("iteration count must be greater than 0", 2) end + assert(activeBench, "no active benchmark") + assert(not activeBench.timeStart, "clock was already started") + + activeBench.iterations = n + activeBench.memStart = gcinfo() + activeBench.timeStart = os.clock() + return n +end + +local function BENCH(name: string, fn: () -> ()) + assert(not activeBench, "cannot run benchmark, a benchmark is already in progress") + + local bench: Bench = {} + activeBench = bench + + local memStart = gcinfo() + local timeStart = os.clock() + local msg: string? + local success = xpcall(fn, function(m: string) msg = m .. debug.traceback("", 2) end) + local timeStop = os.clock() + local memStop = gcinfo() + + if not success then + print("[\27[41;1mERROR\27[0m] " .. name) + print("\27[31;1m" .. "error: " .. msg :: string .. "\27[0m") + activeBench = nil + return + end + + timeStart = bench.timeStart or timeStart + memStart = bench.memStart or memStart + + local n = bench.iterations or 1 + local duration = timeStop - timeStart + local allocated = memStop - memStart + + print(string.format("[ %.3f us | %4.0f B ] %s", duration/n * 1e6, allocated/n * 1e3, name)) + + activeBench = nil +end + +------------------------------------------------------------------------------------------ +-- Printing +------------------------------------------------------------------------------------------ + +local function printa(v: unknown) + type Buffer = { n: number, [number]: string } + + -- overkill concatenationless string buffer + local function tos(value: any, stack: number, str: Buffer) + local TAB = " " + local indent = table.concat(table.create(stack, TAB)) + + if type(value) == "string" then + local n = str.n + str[n + 1] = "\"" + str[n + 2] = value + str[n + 3] = "\"" + str.n = n + 3 + elseif type(value) ~= "table" then + local n = str.n + str[n + 1] = value == nil and "nil" or tostring(value) + str.n = n + 1 + elseif next(value) == nil then + local n = str.n + str[n + 1] = "{}" + str.n = n + 1 + else + local tabbed_indent = indent .. TAB + + str.n += 1 + str[str.n] = "{\n" + + local i, v = next(value, nil) + while v ~= nil do + local n = str.n + str[n + 1] = tabbed_indent + + if type(i) ~= "string" then + str[n + 2] = "[" + str[n + 3] = tostring(i) + str[n + 4] = "]" + n += 4 + else + str[n + 2] = tostring(i) + n += 2 + end + + str[n + 1] = " = " + str.n = n + 1 + + tos(v, stack + 1, str) + + i, v = next(value, i) + + n = str.n + str[n + 1] = v ~= nil and ",\n" or "\n" + str.n = n + 1 + end + + local n = str.n + str[n + 1] = indent + str[n + 2] = "}" + str.n = n + 2 + end + end + + local str = { n = 0 } + tos(v, 0, str) + print(table.concat(str)) +end + +printa "string" + +printa(1) + +printa { + hello = 1, + bye = "ok", + + test = { + 1, 2, 3 + } +} + +------------------------------------------------------------------------------------------ +-- Return +------------------------------------------------------------------------------------------ + +return { + getUnitTestTools = function() + return TEST, CASE, CHECK, FINISH + end, + + getBenchmarkTools = function() + return BENCH, START + end, + + printa = printa +} diff --git a/test/tests.luau b/test/tests.luau new file mode 100644 index 0000000..d199aa5 --- /dev/null +++ b/test/tests.luau @@ -0,0 +1,1293 @@ +---------------------------------------------------------------------------------------------------------------------- +-- unit.lua +---------------------------------------------------------------------------------------------------------------------- + +local TEST, CASE, CHECK, FINISH = require("test/testkit").getUnitTestTools() + +local mock = require "test/mock" +local Signal = require "test/goodsignal" +local Instance, Vector3, Color3, Vector2, UDim2 = mock.Instance, mock.Vector3, mock.Color3, mock.Vector2, mock.UDim2 + +local vide = require "src/init" + +-- force run garbage collector cycles +local function gc(n: number?) + for i = 1, n or 3 do + (collectgarbage :: any)("collect") + end +end + +-- weak reference table used for gc tests +local wref = setmetatable({}, { __mode = "kv" }) :: any + +TEST("graph", function() + local graph = require "src/graph" + local create = graph.create + local get = graph.get + local unwrap = graph.unwrap + local set = graph.set + local capture = graph.capture + local captureAndLink = graph.captureAndLink + local link = graph.link + local setEffect = graph.setEffect + + do CASE "Create" + local node = create(1) + CHECK(get(node) == 1) + end + + do CASE "Read/write" + local node = create(0) + set(node, 1) + CHECK(get(node) == 1) + set(node, 2) + CHECK(get(node) == 2) + end + + do CASE "Capture" + local node1 = create(nil) + local node2 = create(nil) + local nodes = capture(function(from) + return from(node1), from(node2) + end) + CHECK(nodes[1] == node1) + CHECK(nodes[2] == node2) + end + + do CASE "Link" + local parent = create(1) + local child = create(nil) + link(parent, child, function() + return get(parent) + end) + set(parent, get(parent) + 1) + CHECK(get(child) == 2) + end + + do CASE "Capture and link" + local parent = create(1) + local child = create(nil :: any) + + captureAndLink(child, function(from) + return tostring(from(parent)) + end) + + set(parent, get(parent) + 1) + CHECK(get(child) == "2") + end + + do CASE "Scoped captures" + -- table.find but uses `rawequal` since nodes have overloaded __eq metamethod + local function rawfind(t, x) + for i, v in ipairs(t) do + if rawequal(x, v) then + return true + end + end + return false + end + + local a = graph.create(1) + local b = graph.create(1) + + graph.link(a, b, function() return get(a) end) + set(a, get(a) + 1) -- mark `b` for recomputation + + -- `a` and `b` should be referenced + local captures = graph.capture(function(from) return from(b) end) + + -- check that only `b` was referenced + CHECK(not rawfind(captures, a)) + CHECK(rawfind(captures, b)) + + -- repeat for `captureAndLink` + local c = graph.create(1) + set(a, get(a) + 1) -- mark `b` for recomputation again + captureAndLink(c, function(from) return from(b) end) + -- check that only `b` was linked + CHECK(not rawfind(assert(a.children), c)) + CHECK(rawfind(assert(b.children), c)) + end + + do CASE "Nodes garbage collection" + local node = create(1) :: Node? + wref.node, node = node, nil + + gc() + CHECK(not wref.node) + end + + do CASE "Node effect garbage collection" + do + local function factory(p) -- factory function to prevent closure caching + return function() + return unwrap(p) + end + end + + local node = create(1) + local effect1 = factory(node) + local effect2 = factory(node) + wref.node = node + wref.effect1 = effect1 + wref.effect2 = effect2 + + local t = {} + setEffect(node, effect1, t) + setEffect(node, effect2, true) + + t = nil :: any + effect1, effect2 = nil :: any, nil :: any + + gc() + + CHECK(not wref.effect1) -- effect1 should gc since nothing is referencing table `t` + CHECK(wref.effect2) -- effect2 should not gc as `true` is not garbage collectable + end + gc() + CHECK(not wref.node and not wref.effect2) -- node should now gc along with effect2 + + do -- same test but for multiple nodes referenced by watcher + local function factory(a, b) + return function() + return get(a), get(b) + end + end + + local node1 = create(1) + local node2 = graph.create(1) + local effect = factory(node1, node2) + wref.node1 = node1 + wref.node2 = node2 + wref.effect = effect + + local t1 = {} + setEffect(node1, effect, t1) + setEffect(node2, effect, t1) + + t1 = nil :: any + effect = nil :: any + + gc() + + CHECK(not wref.effect) + end + + gc() + + CHECK(not wref.node1) + CHECK(not wref.node2) + end +end) + +TEST("wrap()", function() + local wrap = vide.wrap + local unwrap = vide.unwrap + local wrapped = vide.wrapped + local watch = vide.watch + + do CASE "Wrap value" + local state = wrap(1) + CHECK(wrapped(state)) + CHECK(unwrap(state) == 1) + end + + do CASE "Setter" + local state, set = wrap(1) + set(2) -- set directly + CHECK(unwrap(state) == 2) + set(function(x) return x + 1 end) -- set via function + CHECK(unwrap(state) == 3) + set((wrap(4))) -- set using value of another state + CHECK(unwrap(state) == 4) + end + + do CASE "Does not update if same value" + local state, set = wrap(1) + + local updates = -1 + watch(function(from) + from(state) + updates += 1 + end) + set(1) + CHECK(unwrap(state) == 1) + CHECK(updates == 0) + set(1, true) + CHECK(updates == 1) + end + + do CASE "Does not rewrap state" + local state = wrap((wrap(1))) + CHECK(not wrapped(unwrap(state))) + end +end) + +TEST("unwrap()", function() + local wrap = vide.wrap + local unwrap = vide.unwrap + + do CASE "Gets state value" + local a = wrap(1) + CHECK(unwrap(a) == 1) + end + + do CASE "Allow passthrough of non-state" + CHECK(unwrap(5) == 5) + end +end) + + +TEST("wrapped()", function() + local wrap = vide.wrap + local wrapped = vide.wrapped + + do CASE "Check if value is a state object" + local state = wrap() + CHECK(wrapped(state)) + end + + do CASE "Refuse non-state" + CHECK(not wrapped(0)) + end +end) + +TEST("derive()", function() + local wrap = vide.wrap + local unwrap = vide.unwrap + local derive = vide.derive + + do CASE "Derive new value on state change" + local state, set = wrap(1) + + local derived = derive(function(from) + return tostring(from(state)) + end) + + CHECK(unwrap(derived) == "1") -- check initial run during detection + set(function(x) return x + 1 end) + CHECK(unwrap(derived) == "2") -- check actually updates + end + + do CASE "Shorthand derivation" + do + local count, set = wrap(1 :: any) + local text = "Count: " .. count + CHECK(unwrap(text) == "Count: 1") + set(2) + CHECK(unwrap(text) == "Count: 2") + end + do + local count, set = wrap(1 :: any) + local text = count .. "x" + CHECK(unwrap(text) == "1x") + set(2) + CHECK(unwrap(text) == "2x") + end + do + local count, set = wrap(1 :: any) + local text = count .. count + CHECK(unwrap(text) == "11") + set(2) + CHECK(unwrap(text) == "22") + end + end + + do CASE "Derive from updated" + do + local a, set = wrap(1) + + local b = derive(function(from) + return from(a) + 1 + end) + + set(2) + + local c = derive(function(from) + return from(b) + 1 + end) + + CHECK(unwrap(c) == 4) + end + + do + local a, set = wrap(1) + + local b = a + 1 + + set(2) + + local c = b + 1 + + CHECK(unwrap(c) == 4) + end + end + + do CASE "Cleanup" + local count, set = wrap(1) + + local derived = derive(function(from) + return { Value = from(count), Destroyed = false } + end, function(v) + v.Destroyed = true + end) + + local first = unwrap(derived) + CHECK(first.Destroyed == false) + set(2) + local _ = unwrap(derived) -- trigger recalc + CHECK(first.Destroyed == true) + end + + do CASE "Garbage collection" + do -- check that `derived` does not allow gc of `state` + local state = wrap(1) :: State? + + local _derived = derive(function(from) + return from(state) + end) + + wref.state, state = state, nil + + gc() + CHECK(not wref.state) + end + + do -- check that `state` allows gc of `derived` + local state = wrap(1) + + local derived = derive(function(from) + return from(state) + end) :: State? + + wref.derived, derived = derived, nil + + gc() + CHECK(not wref.derived) + end + end + + do CASE "Garbage collection 2" + -- creats a chain `a -> b -> c` where `a` is the root + local function setup() + local weak = setmetatable({}, { __mode = "v" }) + + local a, setA = wrap(1) + + local b = derive(function(from) + return from(a) + end) + + local c = derive(function(from) + return from(b) + end) + + weak.a = a + weak.b = b + weak.c = c + + return weak, a, b, c, setA + end + + do -- check that b and c can gc if a is referenced + local weak, _a = setup() + + gc() + CHECK(not weak.b) + CHECK(not weak.c) + end + + do -- check that a and b wont gc if c is referenced + local weak, _a, _b, _c = setup() + + _a, _b = nil :: any, nil :: any + + gc() + CHECK(weak.a) + CHECK(weak.b) + end + + do -- check that b wont gc if a and c are referenced + local weak, _a, _b, c, setA = setup() + + _b = nil :: any + + gc() + + setA(2) + + CHECK(weak.b) + CHECK(unwrap(c) == 2) + end + end +end) + +TEST("watch()", function() + local wrap = vide.wrap + local watch = vide.watch + + do CASE "Capture states" + local a, setA = wrap(1) + local b, setB = wrap(1) + local runcount = 0 + + watch(function(from) + local _ = from(a) + from(b) + runcount += 1 + end) + + CHECK(runcount == 1) -- immediate callback execution + setA(2) + CHECK(runcount == 2) + setB(2) + CHECK(runcount == 3) + end + + do CASE "Stop watch" + local a, setA = wrap(1) + local b, setB = wrap(1) + local runcount = 0 + + local unwatch = watch(function(from) + local _ = from(a) + from(b) + runcount += 1 + end) + + CHECK(runcount == 1) + unwatch() + setA(2) + setB(2) + CHECK(runcount == 1) + end + + do CASE "Side-effect cleanup" + local state, set = wrap(1) + + local effect_runcount = 0 + local cleanup_runcount = 0 + + local unwatch = watch(function(from) + local _ = from(state) + effect_runcount += 1 + return function() cleanup_runcount += 1 end + end) + + CHECK(effect_runcount == 1) + CHECK(cleanup_runcount == 0) + set(2) + CHECK(effect_runcount == 2) + CHECK(cleanup_runcount == 1) + unwatch() + CHECK(effect_runcount == 2) + CHECK(cleanup_runcount == 2) + end + + do CASE "Garbage collection" + local function factory(p) + return function(from: any) + from(p) + end + end + + do -- state prevents gc of watcher + local state = wrap(1) + local effect = factory(state) + + watch(effect) + + wref.effect = effect + effect = nil :: any + + gc() + CHECK(wref.effect) -- should still exist + + end + + do -- watcher can gc if stopped + local state = wrap(1) + local effect = factory(state) + + local unwatch = watch(effect) + + wref.effect = effect + effect = nil :: any + + gc() + CHECK(wref.effect) -- should still exist + unwatch(); unwatch = nil :: any + gc() + CHECK(not wref.effect) -- should gc + + end + + do -- state can gc with watcher + local state = wrap(1) + local effect = factory(state) + + watch(effect) + + wref.state = state + state = nil :: any + + effect = nil :: any + + gc() + CHECK(not wref.state) -- should gc + end + + do -- watcher can gc if no state captured + local effect = factory() + + watch(effect) + + wref.effect = effect + effect = nil :: any + + gc() + CHECK(not wref.effect) -- should gc + end + end +end) + +TEST("create()", function() + local create = vide.create + local wrap = vide.wrap + local Children: "Children" = vide.Children :: "Children" + + do CASE "Apply default properties" + local defaults = require("src/defaults") + local frame = create "Frame" {} :: Instance & { BorderSizePixel: any, BorderColor3: any } + CHECK(frame.BorderSizePixel == defaults.Frame.BorderSizePixel) + CHECK(frame.BorderColor3 == defaults.Frame.BorderColor3) + end + + do CASE "Assign custom properties" + local text = create "TextLabel" { + Name = "Label", + Text = "test" + } + CHECK(text.Name == "Label") + CHECK(text.Text == "test") + end + + do CASE "Independent" + local frame = create "Frame" + CHECK(frame {} ~= frame {}) + end + + do CASE "Assign children" + local frame = create "Frame" { + [Children] = { + create "TextLabel" { Name = "A" } :: any, + create "TextLabel" { Name = "B" }, + { + create "TextLabel" { Name = "C" } :: any, + create "TextLabel" { Name = "D" }, + { + create "TextLabel" { Name = "E" } + } + } + } + } + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "B") + CHECK(frame:FindFirstChild "C") + CHECK(frame:FindFirstChild "D") + CHECK(frame:FindFirstChild "E") + + local image = create "ImageLabel" { + [Children] = create "TextLabel" { Name = "A" } + } + CHECK(image:FindFirstChild "A") + end + + do CASE "Binding properties to state" + local name, setName = wrap("Hi") + local text, setText = wrap("Bye") + + local label = create "TextLabel" { + Name = name, + Text = text + } + + CHECK(label.Name == "Hi") + CHECK(label.Text == "Bye") + + setName "Foo" + setText "Bar" + + CHECK(label.Name == "Foo") + CHECK(label.Text == "Bar") + end + + do CASE "Binding garbage collection" + do -- instance should gc despite property bound to state + local state = wrap("Hi") + + do + wref.instance = create "TextLabel" { + Text = state, + } + end + + gc() + CHECK(not wref.instance) + end + + do -- instance should NOT gc despite property bound to state when parented + local state = wrap("Hi") + + local parent = create "Frame" {} + + do + wref.instance = create "TextLabel" { + Parent = parent, + Text = state, + } + end + + gc() + CHECK(wref.instance) + + wref.instance.Parent = nil + wref.instance.Parent = parent + + gc() + CHECK(wref.instance) + + wref.instance:Destroy() + + gc() + CHECK(not wref.instance) + end + + do -- state should not gc once exits scope while instance still exists + local _label + + do + local state = wrap("Hi") + wref.state = state + + _label = create "TextLabel" { + Name = state, + } + end + + gc() + CHECK(wref.state) + end + + do -- state and instance should gc once both exit scope + do + local text = wrap("Hi") + + local box = create "TextLabel" { + Text = text, + } + + wref.text = text + wref.box = box + end + + gc() + CHECK(not wref.state) + CHECK(not wref.box) + + end + + do -- binding should gc despite state still existing after instance is gc + local state = wrap("Hi") + + do + local instance = create "TextLabel" { + Text = state, + } + + wref.instance = instance + wref.binding = next((state :: any).effects) + end + + CHECK(wref.binding) + + gc() + + CHECK(not wref.instance) + CHECK(not wref.binding) + end + end + + do CASE "Bind same state to multiple instance properties" + local state, set = wrap "1" + + local text = create "TextBox" { + Name = state, + Text = state, + PlaceholderText = state + } + + set "2" + + CHECK(text.Name == "2") + CHECK(text.Text == "2") + CHECK(text.PlaceholderText == "2") + end + + do CASE "Bind children" + local state, set = wrap({} :: {}?) + + local a, b, c = + create "TextLabel" { Name = "A" }, + create "TextLabel" { Name = "B" }, + create "TextLabel" { Name = "C" } + + local frame = create "Frame" { + [Children] = state + } + + set { a, b } + + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "B") + + -- check that b is removed and c is added while a remains untouched + + set { a, c } + + CHECK(frame:FindFirstChild "A") + CHECK(frame:FindFirstChild "C") + CHECK(not frame:FindFirstChild "B") + + set(nil) + + CHECK(#frame:GetChildren() == 0) + end + + do CASE "Parent set to nil by state does not allow gc" + -- this is technically a bug but we test for this anyways to confirm behavior + local parent = create "Frame" { Name = "Parent" } + local state, set = wrap(parent :: Frame?) + + do + wref.child = create "Frame" { Parent = state, Name = "Child" } :: Frame? + end + + gc() + CHECK(wref.child) + + set(nil) + + gc() + CHECK(wref.child) + + wref.child:Destroy() + + gc() + CHECK(not wref.child) + end + + do CASE "GC test" + local data = setmetatable({}, {}) + local proxy = setmetatable({}, { __mode = "v" }) + + local ref = setmetatable({}, { __mode = "v" }) + + --proxy.data = data --? (this line should not affect outcome) + -- `data` strongly references `proxy` + data.connection = proxy + + -- `ref` strongly references `data` + ref[data] = proxy + + -- although `ref` is weak to values, `data` keeps `proxy` alive + -- this forms a sort of cyclic reference that the luau gc is unable to detect + + wref.data = data + wref.proxy = proxy + data = nil :: any + proxy = nil :: any + + gc() + + CHECK(wref.data and wref.proxy) + end +end) + +TEST("map()", function() + local wrap = vide.wrap + local unwrap = vide.unwrap + local map = vide.map + + do CASE "Use integer" + local n = 5 + + local t = map(n, function(i) + return tostring(i) + end) + + for i = 1, n do + CHECK(tostring(i) == t[i]) + end + end + + do CASE "Use table" + local t = { 1, 2, 3, 4, 5 } + + local t2 = map(t, function(_, v) + return tostring(v) + end) + + for i, v in t do + CHECK(tostring(v) == t2[i]) + end + end + + do CASE "Use state" + local state = wrap { 1, 2, 3 } + + local derived = map(state, function(_, v) + return tostring(v) + end) + + local t = unwrap(derived) + + for i, v in next, unwrap(state) do + CHECK(tostring(v) == t[i]) + end + end + + do CASE "Cache result" + local state, set = wrap { 1, 2, 3 } + + local runcount = table.create(3, 0) + + local derived = map(state, function(i, v) + runcount[i] += 1 + return tostring(v) + end) + + local _ = unwrap(derived) -- trigger evaluation (so the next set is forced to be re-calculated) + set { 1, 2, 4 } + + local t = unwrap(derived) + + CHECK(t[1] == "1") + CHECK(t[2] == "2") + CHECK(t[3] == "4") + + CHECK(runcount[1] == 1) + CHECK(runcount[2] == 1) + CHECK(runcount[3] == 2) + end + + do CASE "Removal reflected" + local state, set = wrap { 1, 2, 3 } + + local derived = map(state, function(i, v) + return tostring(v) + end) + + local _ = unwrap(derived) -- trigger evaluation (so the next set is forced to be re-calculated) + set { 1, 2 } + + local t = unwrap(derived) + + CHECK(t[1] == "1") + CHECK(t[2] == "2") + CHECK(t[3] == nil) + end + + local create = vide.create + local Children = vide.Children + + do CASE "Bind children" + local state, set = wrap { "A", "B", "C" } + + local derived = map(state, function(i, v) + return create "TextLabel" { + Name = v, + Text = tostring(i) + } + end) + + local frame = create "Frame" { + Name = "21", + [Children] = derived + } + + local function find(childname: string): TextLabel + return frame:FindFirstChild(childname) :: TextLabel + end + + CHECK(find "A".Text == "1") + CHECK(find "B".Text == "2") + CHECK(find "C".Text == "3") + + set { "A", "C", "D" } + + CHECK(find "A".Text == "1") + CHECK(not find "B") + CHECK(find "C".Text == "2") + CHECK(find "D".Text == "3") + end + + do CASE "Use optional destructor" + local state, set = wrap { 1, 2, 3 } + local derived = map(state, function(i, v) + return { Value = v, Destroyed = false } + end, function(v) + v.Destroyed = true + end) + + local first = unwrap(derived) + + CHECK(first[1].Destroyed == false) + + set { 1, 2, 4 } + + local _ = unwrap(derived) + + CHECK(first[1].Destroyed == false) + CHECK(first[2].Destroyed == false) + CHECK(first[3].Destroyed == true) + end + + do CASE "Garbage collection" + do -- check that `derived` does not allow gc of `state` + local state = wrap {} + + local derived = map(state, function(i, v) + return v + end) + + wref.state, state = state, nil :: any + wref.derived = derived + + gc() + CHECK(wref.state) + end + + do -- check that `state` allows gc of `derived` + local state = wrap {} + + local derived = map(state, function(i, v) + return i, v + end) :: State? + + wref.state = state + wref.derived, derived = derived, nil + + gc() + CHECK(not wref.derived) + end + end +end) + +TEST("apply()", function() + local apply = vide.apply + + -- uses same application method as `create()` internally, further testing unnecessary + do CASE "Apply properties" + local part = Instance.new("Part") :: Part + + apply(part) { + Position = Vector3.new(1, 1, 1), + Color = Color3.new(1, 0, 0) + } + + CHECK(part.Position == Vector3.new(1, 1, 1)) + CHECK(part.Color == Color3.new(1, 0, 0)) + end +end) + +TEST("spring()", function() + local wrap = vide.wrap + local unwrap = vide.unwrap + local spring = vide.spring + + do CASE "Update state (on next hearbeat resumption cycle)" + local number, set = wrap(10) + local springed = spring(number, 1, 1) + + set(20) + CHECK(unwrap(springed) == 10) + vide.step(1/60) + CHECK(unwrap(springed) ~= 10) + CHECK(unwrap(springed) > 10) + end + + do CASE "Garbage collection" + do -- `spring` should not allow gc of `state` + local state = wrap(10) + local _springed = spring(state, 1, 1) + + wref.state, state = state, nil :: any + + gc() + CHECK(wref.state) + + end + + do -- `number` should allow gc of `spring` + local number = wrap(10) + local springed = spring(number, 1, 1) :: State? + + wref.springed, springed = springed, nil + + gc() + CHECK(not wref.springed) + + end + end + + local create = vide.create + + do CASE "Garbage collection (binded)" + local number = wrap(10) + local springed = spring(number, 1, 1) :: State? + + local _label = create "TextLabel" { + Text = springed + } + + wref.springed, springed = springed, nil + + gc() + CHECK(wref.springed) -- `springed` should not gc + end +end) + +TEST("Layout", function() + local create = vide.create + local Layout = vide.Layout + + do CASE "Apply layout properties" + local frame = create "Frame" { + [Layout] = { + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.fromScale(0.5, 0.5) + } + } + + CHECK(frame.AnchorPoint == Vector2.new(0, 0.5)) + CHECK(frame.Position == UDim2.fromScale(0.5, 0.5)) + end +end) + +TEST("Event", function() + local create = vide.create + local Event = vide.Event + local wrap = vide.wrap + + do CASE "Connect event" + local connected = false + + local event = (Signal.new() :: any) :: RBXScriptSignal & { Fire: any } + + local val = create "IntValue" { + Changed = event, + [Event.Changed] = function(newval) + connected = true + CHECK(newval == 1) + end + } :: IntValue + + CHECK(not connected) + val.Value = 1; event:Fire(val.Value) + vide.step(1/60) + CHECK(connected) + end + + do CASE "Bind connection to state" + local countA = 0 + local countB = 0 + local listener, set = wrap(function() countA += 1 end :: () -> ()?) + + local event = (Signal.new() :: any) :: RBXScriptSignal & { Fire: any } + + local int = create "IntValue" { + Changed = event, + [Event.Changed] = listener + } :: IntValue + + int.Value = 1; event:Fire(int.Value) + int.Value = 2; event:Fire(int.Value) + CHECK(countA == 2) + set(function() return function() countB += 1 end end) + int.Value = 3; event:Fire(int.Value) + int.Value = 4; event:Fire(int.Value) + CHECK(countA == 2) + CHECK(countB == 2) + set(nil) + int.Value = 5; event:Fire(int.Value) + CHECK(countA == 2) + CHECK(countB == 2) + end + + do CASE "Always return same object for a given index" + CHECK(Event.Test == Event.Test) + end +end) + +TEST("Changed", function() + local create = vide.create + local Changed = vide.Changed + local wrap = vide.wrap + + do CASE "Connects event" + local connected = false + local label = create "TextLabel" { + [Changed.Text] = function(text) + CHECK(text == "hi") + connected = true + end + } + CHECK(not connected) + label.Text = "hi" + CHECK(connected) + end + + do CASE "Bind connection to state" + local countA = 0 + local countB = 0 + local listener, set = wrap(function() countA += 1 end :: () -> ()?) + + + local label = create "TextLabel" { + [Changed.Text] = listener + } + + label.Text = "1" + label.Text = "2" + CHECK(countA == 2) + set(function() return function() countB += 1 end end) + label.Text = "3" + label.Text = "4" + CHECK(countA == 2) + CHECK(countB == 2) + set(nil) + label.Text = "5" + CHECK(countA == 2) + CHECK(countB == 2) + end + + do CASE "Always return same object for a given index" + CHECK(Changed.Test == Changed.Test) + end +end) + +TEST("Created", function() + local create = vide.create + local Created = vide.Created + + do CASE "Run after instance creation" + local ran = false + + create "Frame" { + A = true, + B = true, + C = true, + [Created] = function(self) + ran = true + CHECK(self.A and self.B and self.C) + end + } + + CHECK(ran) + end +end) + +TEST("strict", function() + vide.strict = true + + local wrap = vide.wrap + local unwrap = vide.unwrap + local derive = vide.derive + local watch = vide.watch + + do CASE "Error on derived callback yield" + local state = wrap(1) + + local ok = pcall(function() + local _derived = derive(function(from) + coroutine.yield() + return from(state) + end) + end) + + CHECK(not ok) + end + + do CASE "Error on watcher callback yield" + local state = wrap(1) + + local ok = pcall(function() + local _derived = watch(function(from) + coroutine.yield() + local _ = from(state) + end) + end) + + CHECK(not ok) + end + + do CASE "Run derived callback twice" + local state, set = wrap(1) + local runcount = 0 + + local derived = derive(function(from) + runcount += 1 + return from(state) + end) + + CHECK(runcount == 2) + set(2) + local _ = unwrap(derived) + CHECK(runcount == 4) + end + + do CASE "Run watcher callback twice" + local state, set = wrap(1) + local runcount = 0 + + watch(function(from) + runcount += 1 + local _ = from(state) + end) + + CHECK(runcount == 2) + set(2) + vide.step(1/60) + CHECK(runcount == 4) + end + + do CASE "Does not allow non-layout properties" + local ok = pcall(function() + vide.create "Frame" { + [vide.Layout] = { + AnchorPoint = Vector2.new(0, 0.5), + BackgroundColor3 = Color3.new(0, 0, 0) + } + } + end) + CHECK(not ok) + end + + do CASE "Does not allow same table set" + local _, set = wrap() + + local t = {} + + set(t) + + local ok = pcall(set, t) + + CHECK(not ok) + end + + -- todo: add case for strict mode bindings +end) + +local ok = FINISH() +if not ok then error("Tests failed", 0) end + +return nil diff --git a/test/wrap-require.lua b/test/wrap-require.lua new file mode 100644 index 0000000..824036c --- /dev/null +++ b/test/wrap-require.lua @@ -0,0 +1,8 @@ +local function dir(directory: string) + return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end }) +end + +local script = dir "src/" +script.Parent = dir "src/" + +return script diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..3c6a999 --- /dev/null +++ b/todo.md @@ -0,0 +1,6 @@ +# Todo + +- investigate solid js `for` and batching +- equality checking of derived state updates to prevent redundant updates of derived state +- watcher callbacks when watcher watches a parent and child +- stores diff --git a/vide.code-snippets b/vide.code-snippets new file mode 100644 index 0000000..b7caaa0 --- /dev/null +++ b/vide.code-snippets @@ -0,0 +1,26 @@ +{ + "import vide": { + "scope": "lua", + "prefix": "getvide", + "body": [ + "local vide = require($1.vide)", + "type State = vide.State", + "type Prop = vide.Prop", + "local create = vide.create", + "local apply = vide.apply", + "local wrap = vide.wrap", + "local derive = vide.derive", + "local foreach = vide.foreach", + "local match = vide.match", + "local watch = vide.watch", + "local spring = vide.spring", + "local Event = vide.Event", + "local Changed = vide.Changed", + "local Bind = vide.Bind", + "local Layout = vide.Layout", + "local Children = vide.Children", + "local Created = vide.Created", + "" + ] + } +} \ No newline at end of file diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..d72916f --- /dev/null +++ b/wally.toml @@ -0,0 +1,10 @@ +[package] +name = "centau/ecr" +description = "A fast, lightweight Luau ECS library." +version = "0.4.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +include = ["src", "LICENSE.md", "default.project.json"] +exclude = ["CHANGELOG.md", ".gitignore", ".github", ".gitattributes", "test", "docs", "README.md", ".luaurc"] + +[dependencies]