diff --git a/.moonwave/static/TutorialAssets/Chapter1/State/CoinsCountToTen.gif b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsCountToTen.gif new file mode 100644 index 00000000..c9880491 Binary files /dev/null and b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsCountToTen.gif differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/State/CoinsPoundsFormat.gif b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsPoundsFormat.gif new file mode 100644 index 00000000..35f54ef5 Binary files /dev/null and b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsPoundsFormat.gif differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/State/CoinsReactive.gif b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsReactive.gif new file mode 100644 index 00000000..18485b2c Binary files /dev/null and b/.moonwave/static/TutorialAssets/Chapter1/State/CoinsReactive.gif differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterFull.jpg b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterFull.jpg new file mode 100644 index 00000000..6b595bea Binary files /dev/null and b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterFull.jpg differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterIntermediaryInstanceOut.jpg b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterIntermediaryInstanceOut.jpg deleted file mode 100644 index 14dce742..00000000 Binary files a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterIntermediaryInstanceOut.jpg and /dev/null differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterSimple.jpg b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterSimple.jpg new file mode 100644 index 00000000..7c7c498b Binary files /dev/null and b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/CoinCounterSimple.jpg differ diff --git a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounter.rbxmx b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounter.rbxmx index 7a70f1d8..48a12be6 100644 --- a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounter.rbxmx +++ b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounter.rbxmx @@ -49,7 +49,7 @@ false false 0 - CoinCointer + CoinCounter null null null diff --git a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounterScreenshot.jpg b/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounterScreenshot.jpg deleted file mode 100644 index ff47024d..00000000 Binary files a/.moonwave/static/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounterScreenshot.jpg and /dev/null differ diff --git a/docs/Chapter1/State.md b/docs/Chapter1/State.md index 73f87454..f57e4733 100644 --- a/docs/Chapter1/State.md +++ b/docs/Chapter1/State.md @@ -4,89 +4,137 @@ sidebar_position: 5 # Creating & Mapping State -:::warning -Tutorials are still in progress, and will be released section-by-section -::: +At its heart, Dec is also a language for representing ***State***. State is any +hidden variable that, when changed, will eventually lead to something updating +in a User Interface. State can be things like coins, whether or not a player is +hovering/pressing over a UI component, the price/display name/thumbnail of an +item being sold to the user, and more. Creating reactive UI Components in Dec +requires breaking down what simple variables are being displayed to the user +on-screen. + +In the last section, we showed an example of a shop menu which appears when a +player interacts with an NPC: -## Using State in your Components +![NPC Shop](/TutorialAssets/Chapter1/VirtualInstance/GecsSeafaringSupplies.jpg) -At its heart, Dec is also a language for representing ***State***, and having -instances reactively change their properties based on changes in state. States -are any variables underlying what's being rendered. Writing effective Dec -applications also requires down what *States* each component is rendering: +We can break this UI down into *States* by thinking about what hidden variables +are being conveyed to the user. Here's a breakdown of what variables some of +these UI components are using: -(image of state breakdown) +### `ShopHeader`: +```lua +local title: string -- The display name of the NPC's shop +``` -In the previous section, we created a CoinCounter UI using a premade template. -In this example, the only state that CoinCounter needs to render is a number -representing how many coins a player has. We can create this state using -`Dec.State`, passing in an initial value for the coins as a first argument: +### `CoinCounter`: +```lua +local coins: number -- How many coins the player currently has +``` + +### `ShopItem`: +```lua +local id: string -- What's the ID of the item we're showing? +local thumbnail: string -- What's the ID of the image thumbnail we're showing? +local displayName: string -- e.g. "Flintlock", "Sword" +local price: number -- How many coins does this cost? +``` + +Let's focus in on the `CoinCounter` Component that was covered in the last +section, and let's represent this "coins" variable as a state. + +## `CoinCounter` Component + +We can create a State that holds a number representing the player's coins by +calling the function [Dec.State](/api/State): ```lua local coins = Dec.State(0) ``` -Dec States can be updated using `Set()`, and their current value can be -retreived using `Current()`: +Then, we can use `:Set()` to update this state and `:Current()` to get its +current value: + ```lua print(coins:Current()) -- 0 coins:Set(42) print(coins:Current()) -- 42 ``` -So far, we've seen VirtualInstances defining the static properties of an -instance to be renderered — however, we can also have these properties change -*reactively* to state +Let's now utilize this state in our UI. + +So far, we've seen examples of using ***VirtualInstances*** to assign static +properties of an instance: +```lua +local coinsLabel = Dec.Premade("TextLabel", { + Text = "42", +}) +``` + +Dec also supports passing in *States* to the property table of a virtual +instance. Doing so will cause the UI to automatically update whenever this +state changes! + ```lua local coinsLabel = Dec.Premade("TextLabel", { Text = coins, }) ``` -(Image of coin counter showing 42 coins) +![Reactive Coins UI](/TutorialAssets/Chapter1/State/CoinsReactive.gif) + +The way in which Dec Components generate visuals from State follows a software +paradigm called [*Reactive Programming*](https://en.wikipedia.org/wiki/Reactive_programming). +To gain a better understanding of how Reactive Programming works in Dec, let's +quickly go over a core concept in Dec: ***Observables*** ## Observables -States are actually a type of ***Observable***. Observables are objects that, -generally speaking, *hold some value* and can *listen to changes in this value*. -`Dec.State` is a special type of Observable in that its value can be written -to; however, many times, we only need to read the value of an Observable rather -than writing to it. +***State*** actually [*inherits*](https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)) +from a base class called ***Observable***. Observables are objects that *hold +some value* and can *listen to changes in this value*. -Observables can be passed as ***Props*** to a Component. Props is essentially -a table of named parameters to a Dec component which affects the final -output of the component: +`Dec.State` is a special type of Observable in that its value can be *written +to;* however, some observables are "read-only" and their value depends on other +factors. + +Let's pass "coins" as a paramater to the CoinCounter component. "coins" is both +a ***State*** object and an ***Observable*** object; however, since we only need +to read from this state within the `CoinCounter` component, we can type this +parameter as an ***Observable*** to make the component more re-usable. ```lua -local function CoinCounter(props: { - coins: Dec.Observable -}) +local function CoinCounter(coins: Dec.Observable) return Dec.Premade("Frame", {}, { CoinsLabel = Dec.Premade("TextLabel", { - Text = props.coins, + Text = coins, }) }) end ``` -In this example, we can use the CoinCounter to modify a premade asset by -updating the text label "CoinsLabel" to match the value of an observable state -we pass in: +If we now render this component in our application, we can write some code that +1) Creates a Coins state; 2) Creates a CoinCounter that reactively renders this +state; and 3) updates this state over time. ```lua --- CoinCounter will start rendering "0" +-- Create a state to hold coins local coins = Dec.State(0) -local CoinCounter = CoinCounter({ - coins = coins, -}) -task.wait(3) +-- Render a new CoinCounter component within a Dec.Root object (presumed to +-- exist in this scope) +root:Render(CoinCounter(coins)) -coins:Set(42) -- CoinCounter will now render with "42" after 3 seconds +-- Increment the value of coins every second +while task.wait(1) do + coins:Set(coins:Current() + 1) +end ``` -(video of animation changing the coins display from 0 to 42 in realtime after 3 -seconds) +![Reactive Coins UI](/TutorialAssets/Chapter1/State/CoinsCountToTen.gif) + +The `CoinCounter` is now a fully *reactive* Component, as it generates visuals +based on the value of an ***Observable***, and updates these visuals whenever +the Observable's value changes! ## Mapping Observables @@ -96,9 +144,11 @@ and two decimal points, so that `42` shows up as `£42.00`. To do this, we will need to transform the coins state somehow. ***Mapping*** Is the process of transforming one observable to another by using -a mapping functions. Mapping is achieved in Dec by using `Dec.Map`, with the -input observables as arguments. This function returns another function -which should be called with the ***Mapping Function*** as a first argument: +a transformation function. Mapping is achieved in Dec by calling +[Dec.Map](/api/Dec#Map) with the state we want to map, then calling the returned +value again with a transformation function. + +The ***Mapping*** syntax looks like this: ```lua local coinsFormatted = Dec.Map(coins)(function(currentCoins) @@ -106,16 +156,37 @@ local coinsFormatted = Dec.Map(coins)(function(currentCoins) end) ``` -The `coinsFormatted` object is an Observable object which updates its value -based on the current value of coins: +Here we created a an ***Observable string***, whose value depends on the +current value of coins (an observable number). Updating the `coins` state will +also update the value of `coinsFormatted`: ```lua coins:Set(123) print(coinsFormatted:Current()) -- £123.00 ``` -Mapping functions can take in multiple arguments. For example, if you wanted to -also store the currency as a state, you could map it like so: +Let's use a mapped value to format the `coins` observable in our `CoinCounter` +component example: +```lua +local function CoinCounter(coins: Dec.Observable) + return Dec.Premade("Frame", {}, { + CoinsLabel = Dec.Premade("TextLabel", { + Text = Dec.Map(coins)(function(currentCoins) + return string.format("£%.2f", currentCoins) + end), + }) + }) +end +``` + +![Mapping In Use](/TutorialAssets/Chapter1/State/CoinsPoundsFormat.gif) + +## Mapping Multiple Values + +***Observable Mapping*** can take in multiple inputs. For example, if you wanted +to derive a value from `coins` and a `currency` type, you would simply call +`Dec.Map` with two arguments: + ```lua local coins = Dec.State(0) local currency = Dec.State("£") @@ -125,40 +196,40 @@ local coinsFormatted = Dec.Map(currency, coins)(function( ) return string.format("%s%.2f", currentCurrency, currentCoins) end) - print(coinsFormatted:Current()) -- £0.00 + coins:Set(42) currency:Set("$") print(coinsFormatted:Current()) -- $42.00 ``` -We can also use `coins:Map()` as a shorthand—however, doing so will limit the -mapping function to one argument, and will discard the type information of the -returned observable. It's recommended to only call the `:Map()` in cases -like VirtualInstances properties, the value returned by `:Map()` is only used -in one place and never stored directly in a variable: +:::info +Dec provides a shorthand method for mapping an single input observable to a +single output observable called [:Map()](/api/Observable#Map). Due to a current +Luau language limitation, calling the `:Map()` method will discard the type +information of the output observable, so you should prefer using `Dec.Map` +over `Observable:Map()` in most cases for the sake of type safety. +`:Map()` is still useful in situations where you do not need the type of the +output observable, such as when storing it as a VirtualInstance property: ```lua -local function CoinCounter(props: { - coins: Dec.Observable +return Dec.Premade("TextLabel", { + Text = coins:Map(function(currentCoins) + return string.format("£%.2f", currentCoins) + end), }) - return Dec.Premade("Frame", {}, { - CoinsLabel = Dec.Premade("TextLabel", { - Text = props.coins:Map(function(currentCoins) - return string.format("£%.2f", currentCoins) - end), - }) - }) -end ``` - -(Image of formatted CoinCounter UI) +::: ## Using Math Operations on Observables -Dec provides operator overloads for observables of the same number or vector +:::caution +This feature has been disabled due to a regression in Luau's type system. +::: + +~~Dec provides operator overloads for observables of the same number or vector type! You can use operators like `+`, `-`, `*`, `/`, and `^` between two -observable objects to get a mapped observable: +observable objects to get a mapped observable:~~ ```lua local a = Dec.State(3) @@ -169,25 +240,29 @@ a:Set(4) print(sum:Current()) -- 8 ``` -In the above example, `sum` is equivalent to mapping `a` and `b` with an -summation mapping function: +~~In the above example, `sum` is equivalent to mapping `a` and `b` with an +summation mapping function:~~ ```lua local sum = Dec.Map(a, b)(function(currentA, currentB) return currentA + currentB end) ``` -## Subscribing to State +~~You can also use math overloads on an Observable and its same value type. For +example, you can add a UDim2 with an *Observable UDim2*:~~ -One nice thing about Observables in Dec is that they will always be garbage -collected (freed from memory) whenever they go *unused*. Observables are only -considered to be "used" if they are used by an active VirtualInstance, or if -they are ***Subscribed*** +```lua +local basePosition: Dec.Observable = Dec.State(UDim2.fromScale(0.5, 0.1)) +local PADDING = UDim2.fromScale(0.05, 0.05) +local paddedPosition = basePosition + PADDING +print(paddedPosition:Current()) -- ~ {0.55, 0}, {0.15, 0} +``` -Subscribing to an observable lets you listen to changes in the value. For -example, if you wanted to print every time a certain value changes, you could do -so by calling `value:Subscribe()`, which in turn returns an `unsubscribe` -function +## Subscribing to State + +The primary feature of Observables is, of course, that they can be *observed*. +This is done by calling the `:Subscribe()` method, which calls a listener +whenever the observable's value changes, and can be unsubscribed. ```lua local value = Dec.State(42) @@ -195,33 +270,41 @@ local unsubscribe = value:Subscribe(function(currentValue) print("The current value is ", currentValue) end) value:Set(128) -- The current value is 128 +value:Set(256) -- The current value is 256 ``` -:::warning +:::danger If you directly Subscribe to Observables, **make sure to always handle the returned unsubscribe function** when the observable is no longer needed. More complex observables may stick around in memory until they are unsubscribed! ::: +Observables have one caveat that they *might* stick around in memory as long as +there is a listener subscribing to them. This is because, as we will cover in +later sections, some observables like *Stopwatches*, *Springs*, etc. need to +bind to Heartbeat to update their value every frame. Subscribing to an +Observable for the first time may *set up* side effects that will only be taken +down once the Observable is *unsubscribed* by all listeners. + ### A Safer Alternative to `:Subscribe()` A safe alternative to calling `Observable:Subscribe()` is the function `Observable:SubscribeWhileMounted()`, which takes in a VirtualInstance as a first parameter, and automatically unsubscribes to the observable's value once -the VirtualInstance is `unmounted` (i.e. is no longer being rendered by dec). +the VirtualInstance is ***Unmounted*** (i.e. is no longer being rendered by +Dec). -This is useful for components that have side effects on an input observable, -such as printing: +We can use this inside a component to safely handle side effects and debugging +in a way that cleans itself up when the Component's Virtual Instances stop being +rendered by Dec: ```lua -local function ComponentWithSideEffects(props: { - value: Dec.Observable -}) +local function ComponentWithSideEffects(value: Dec.Observable) local frame = Dec.Premade("Frame") - -- This will keep printing until ComponentWithSideEffects is no longer - -- rendered. - props.value:SubscribeWhileMounted(frame, function(currentValue) + -- This will keep printing changes to the value until the frame is no longer + -- rendered + value:SubscribeWhileMounted(frame, function(currentValue) print("The current value is", currentValue) end) diff --git a/docs/Chapter1/VirtualInstance.md b/docs/Chapter1/VirtualInstance.md index 31ec18a9..f491f6ba 100644 --- a/docs/Chapter1/VirtualInstance.md +++ b/docs/Chapter1/VirtualInstance.md @@ -100,7 +100,7 @@ local coinCounter = Dec.New("Frame", { So far, this translates to the following instance tree: -![Instance Tree Visualization](/TutorialAssets/Chapter1/VirtualInstance/CoinCounterIntermediaryInstanceOut.jpg) +![Instance Tree Visualization](/TutorialAssets/Chapter1/VirtualInstance/CoinCounterSimple.jpg) In order to make this a proper ***Dec Component***, we should create a function named `CoinCounter` which returns this virtual instance tree. @@ -192,7 +192,7 @@ downloadable template: This can be placed directly in StarterGui and used by Dec: -![Premade Coin Counter UI in StarterGui](/TutorialAssets/Chapter1/VirtualInstance/PremadeCoinCounterScreenshot.jpg) +![Premade Coin Counter UI in StarterGui](/TutorialAssets/Chapter1/VirtualInstance/CoinCounterFull.jpg) Once the template is in place, the `CoinCounter` component's code can be greatly simplified to only modify the text of the CoinsLabel object, since that is the diff --git a/proposals/clock.md b/proposals/clock.md new file mode 100644 index 00000000..6d6b8239 --- /dev/null +++ b/proposals/clock.md @@ -0,0 +1,41 @@ +# Proposal: Dec.Clock observable subclass + +This is a proposal to add an observable subclass, `Dec.Clock`, which tracks +the current result of `os.time()` and updates every second exactly when +`os.time()`'s return value updates + +# Status: Unimplemented + +## Use Cases: + +This has many use cases in live events, countdown timers, and other widgets +which need to update their value exactly once per second. + +## Example: + +```lua +local function CountdownTimer() + return Dec.Premade("TextLabel", { + Text = Dec.Clock():Map(function(currentTimeUTC) + local remaining = endTimeUTC - currentTimeUTC + + if remaining <= 0 then + return "Event Has Ended!" + end + + return string.format( + "%d Seconds Remaining . . .", + remaining + ) + end) + }) +end +``` + +`Dec.Clock()` can just return a frozen singleton object, as subscribing to +real-time clock updates only needs to happen once in a whole application, and +clocks will always output the same value no matter what. + +Dec's update stream system also means that `Dec.Clock()` will only bind to +render step / heartbeat exactly when and if it's necessary to do so in any +visible UI. \ No newline at end of file diff --git a/proposals/readonlyDict.md b/proposals/readonlyDict.md new file mode 100644 index 00000000..a7daabe3 --- /dev/null +++ b/proposals/readonlyDict.md @@ -0,0 +1,55 @@ +## Proposal: Readonly Dict/Record types + +This is a proposal to add type-casting overloads to the `Dec.Dict` and +`Dec.Record` constructor functions, which can cast an existing observable state +to a Record/Dict type, allowing indexing an arbitrary state, so long as its base +type is a table. + +## Status: Unimplemented + +## Use Cases: + +`Record:Index` and `Dict:Index` are very powerful methods, as they reduce the +time complexity of state updates that may happen in UI code from O(N) to O(1) +for free. + +A very common use case for this would be something like an infinite-scroll shop +component, which contains many different cards. Sometimes state is held +monolithically, and needs to be distilled to a number of smaller states, but +doing so is difficult if `Dict` and `Record` is unable to be directly used. + +This proposal adds an overload to `Dec.Dict()` and `Dec.Record` that wraps/ +essentially typecasts an existing Observable object to a Dict/Record, provided +the root observable's type is a table. + +## Examples + +One way this can be used is indexing parts of a state passed in props. +```lua +function Component(props: { + info: Dec.Observable<{ + id: string, + displayName: string, + description: string, + }> +}) + -- Cast as Dec.ReadOnlyRecord to allow indexing + local info = Dec.Record(valueFromProps) + + return Dec.Premade("Frame", {}, { + Title = Dec.Premade("TextLabel", { + Text = info:Index("displayName"), + }), + Body = Dec.Premade("TextLabel", { + Text = info:Index("description"), + }) + }) +end +``` + +Here, using `info:Index()`` simplifies a more complex 3-line expression: +```lua +Text = info:Map(function(currentInfo) + return info.displayName, +end) +``` \ No newline at end of file diff --git a/proposals/sequence.md b/proposals/sequence.md index 6b698c02..3136e718 100644 --- a/proposals/sequence.md +++ b/proposals/sequence.md @@ -1,10 +1,21 @@ -# API Propposal: Timed sequences +# Proposal: Timed sequences + +This is a proposal to add a observable subclass, created via `Dec.Sequence`, +which is an object that holds state about a timed sequence. # Status: Unimplemented -This is pretty important for a number of use cases. I figure, since Dec already -contains a number of utility subclasses for observable, having one for sequences -would be immeasurably useful, provided the API is easy to get a hang of. +## Use Case: + +Sequences are pretty important for a number of use cases involving animations +that have multiple steps. + +I figure, since Dec already contains a number of utility subclasses for +observables involving animations/timing, having one for discretely-steped +sequences would be immeasurably useful, provided the API is easy to get a hang +of. + +## Example: ```lua --!nocheck diff --git a/src/Observables/Observable.luau b/src/Observables/Observable.luau index 48748a29..72fc4421 100644 --- a/src/Observables/Observable.luau +++ b/src/Observables/Observable.luau @@ -487,35 +487,33 @@ local function createMappingMetamethod( binop: (a: number, b: number) -> number ): (any, any) -> any return function(a, b) - if IsObservable(a) then - if IsObservable(b) then - -- Map from two constant observables - return Observable.new(function() - return applyBinopPolymorphic( - a:Current(), - b:Current(), - binop - ) - end, function(notifyUpdates) - local unsubscribeA = a:Subscribe(notifyUpdates) - local unsubscribeB = b:Subscribe(notifyUpdates) - return function() - unsubscribeA() - unsubscribeB() - end - end) - else - -- lhs is observable, rhs is constant - return a:Map(function(aCurrent) - return applyBinopPolymorphic(aCurrent, b, binop) - end) - end - else + if not IsObservable(a) then -- rhs is observable, lhs is constant return b:Map(function(bCurrent) return applyBinopPolymorphic(a, bCurrent, binop) end) end + if not IsObservable(b) then + -- lhs is observable, rhs is constant + return a:Map(function(aCurrent) + return applyBinopPolymorphic(aCurrent, b, binop) + end) + end + -- Map from two constant observables + return Observable.new(function() + return applyBinopPolymorphic( + a:Current(), + b:Current(), + binop + ) + end, function(notifyUpdates) + local unsubscribeA = a:Subscribe(notifyUpdates) + local unsubscribeB = b:Subscribe(notifyUpdates) + return function() + unsubscribeA() + unsubscribeB() + end + end) end end Observable.__add = createMappingMetamethod(function(a, b) diff --git a/testing_proof_checksum.txt b/testing_proof_checksum.txt index 35111f04..c09b77ed 100644 --- a/testing_proof_checksum.txt +++ b/testing_proof_checksum.txt @@ -1 +1 @@ -30ca881_112_0_0_success \ No newline at end of file +2d31de06_112_0_0_success \ No newline at end of file diff --git a/wally.toml b/wally.toml index 3810d457..c6c3d0db 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "ambergracesoftware/dec" description = "A libary for building Roblox user interfaces" -version = "0.1.1" +version = "0.1.2" authors = [ "Amber Grace" ] license = "MIT" registry = "https://github.com/UpliftGames/wally-index"