Composing views across time: viewroutine
import {Promises} from "/components/promises.js"
Sometimes you want to put a sequence of UI steps in a single cell. Using inspiration drawn from Unity and Golang (coroutines and goroutines) checkout the viewroutine. A viewroutine leans on Javascript's async generator functions to compose views across time.
```
~~~
viewroutine(generator: async function*) => viewof
~~~
```
The import:-
```
~~~js
import {viewroutine, ask} from '@tomlarkworthy/viewroutine'
~~~
```
What is a view again?
A view
- contains a visual DOM component (viewof foo)
- contains a data component (foo) as the value of the visual component (viewof foo.value)
- Emits input events to signal listeners that the data value has changed
- Like all cells, the viewof cell can be a generator as well and be its own stream
(see also https://observablehq.com/@observablehq/introduction-to-views)
What is an async generator?
Async generators
- Have a signature like async foo*()
- have intermediate return values in the body with yield
- can have a final return value with return
- can use await in the body
- can bulk return the results of other generators with yield*
(see also https://observablehq.com/@observablehq/introduction-to-generators)
Putting it together
The broad idea of a viewroutine, is that an async generator yields a stream of visual components, and we update an overarching span by setting its only child to be those stream of values. Thus, the span becomes a view that doesn't invalidate when the generator yields.
There are a few nice properties with this. You can have variables declared in the closure that are carried between yields. This can often replace the use of an overarching mutable in Observable.
You can compose generators by using the yield* syntax making things compose nicely.
You can on demand and programatically drive the sequence, wait for user input, make choices etc. You could probably build an entire app in this way, and it can be decomposed into functional pieces.
One other important aspect of views is programmatic control over when an input event is raised. The viewroutine will emit an event if yielded.
Pattern we are trying to fix
We want to avoid stuffing a model into a mutable and asynchronously updating that from a dedicated input cell. It takes up too many cells and the use of mutable has lots of unexpected implications such as not working when imported from other notebooks
//VERIFY MUTABLE
//mutable nameOfThing = undefined
const nameOfThing = Mutable(undefined)
const newName = view(Inputs.text({
label: "please enter the name of the thing to create",
submit: true,
minlength: 1
}))
const sideEffect = async function* (newName) {
yield md`<mark>updating`;
await new Promise(r => setTimeout(r, 1000));
nameOfThing.value = newName;
yield md`<mark>updated!!!`;
}
The viewroutine
function viewroutine(generator) {
let current;
const holder = Object.defineProperty(
document.createElement("span"),
"value",
{
get: () => current?.value,
set: (v) => (current ? (current.value = v) : null),
enumerable: true
}
);
new Promise(async () => {
const iterator = generator();
const n = await iterator.next();
let { done, value } = n;
while (!done) {
if (value instanceof Event) {
holder.dispatchEvent(value);
} else {
current = value;
if (holder.firstChild) holder.removeChild(holder.firstChild);
if (value) {
holder.appendChild(value);
}
}
({ done, value } = await iterator.next());
}
holder.remove();
});
return holder;
}
Example
ask wraps any input. It yields the passed in input to be its visual representation, but its final return is the value submitted, which ends the routine (allowing an enclosing generator to continue with the sequence)
Now we can do the same thing without a mutable, even carrying the inputed name in the first step to steps further along.
const example1 = view(viewroutine(async function*() {
let newName = undefined;
while (true) {
newName = yield* ask(
Inputs.text({
label: "please enter the name of the thing to create",
minlength: 1,
value: newName,
submit: true
})
);
yield md`<mark>updating to ${newName}`; // Note we can remember newName
await new Promise(r => setTimeout(r, 1000)); // Mock async action
yield* ask(htl.html`${md`<mark>updated`} ${Inputs.button("Again?")}`);
}
}))
example1
Animation Example with return values
Mixing HTML with SVG and composing animations
choice
const choice = view(viewroutine(async function*() {
while (true) {
const choice = yield* choose();
if (choice == 'square') yield* flashSquare();
if (choice == 'star') yield* flashStar();
}
}))
async function* choose() {
let resolve;
yield Object.defineProperty(
htl.html`<button onclick=${() =>
resolve('star')}>click to play star</button>
<button onclick=${() =>
resolve('square')}>click to play square</button>`,
'value',
{
value: 'undecided'
}
);
yield new Event("input", { bubbles: true });
return await new Promise(function(_resolve) {
resolve = _resolve;
});
}
async function* flashSquare() {
for (let index = 0; index < 360; index += 5) {
yield Object.defineProperty(
html`<span style="display:inline-block; width:50px;height:50px; background-color: hsl(${index}, 50%, 50%);"></span>`,
'value',
{
value: "square"
}
);
if (index === 0) yield new Event("input", { bubbles: true });
await Promises.delay(10);
}
}
async function* flashStar() {
for (let index = 0; index < 360; index += 5) {
yield Object.defineProperty(
htl.svg`<svg height="50" width="50" viewbox="0 0 200 200">
<polygon points="100,10 40,198 190,78 10,78 160,198"
style="fill:hsl(${index}, 50%, 50%);" /></svg>`,
'value',
{
value: "star"
}
);
if (index === 0) yield new Event("input", { bubbles: true });
await Promises.delay(10);
}
}
//import { footer } from "@tomlarkworthy/footer"
//footer