tea_dom_counter

The whole io surface of the demo: one call.

examples/projects/tea_dom_counter/src/main.dawn

# The whole io surface of the demo: one call.
#
# The same binary answers a browser and a terminal, because the boundary is
# lines of JSON on stdin and stdout and nothing else. Built for wasm
# (`dawnc build --target wasm --reactor`) it is a reactor the page calls once
# per event; run from a shell (`dawn run`) it is a session you can type at,
# which is how the example-main contract holds it to a transcript without a
# browser or a wasm engine in sight.
#
#   $ echo '{"op":"init"}' | dawn run examples/projects/tea_dom_counter

use tea_core/app.{update, view}
use tea_dom/node.{Node}
use tea_dom/reactor.{serve}
use counter.{Counter, Msg, init, encode, decode}

pub fn main() -> Unit !io =
  serve(init(), encode, decode, upd, vw)

# The two hooks, named rather than passed as lambdas: `serve` needs a concrete
# message type to settle `M: Eq` against, and a lambda at the call site has no
# expectation to settle it from. `tea_core/app`'s header is the long version
# of why a driver cannot take these through the trait.
fn upd(m: Counter, msg: Msg) -> Counter = update(m, msg)

fn vw(m: Counter) -> Node[Msg] = view(m)

examples/projects/tea_dom_counter/src/counter.dawn

# The pure half of the demo: a model, four messages, `update` and `view`, and
# the two functions that turn a model into opaque text and back. No io, no
# wire format, no host -- everything here is testable with `==`, which is the
# point of the vocabulary carrying messages rather than callbacks.
#
# The view is shaped to make the reconciler visible rather than to look good.
# Three things change independently:
#
#   the count text       every message: one `replace` deep in the tree
#   the root's class     only when the sign of the count changes: one
#                        `set-self` at the root, whose payload must not carry
#                        the document underneath it
#   nothing else         the heading and the four buttons are the same nodes
#                        in every frame and no patch may ever mention them
#
# `Boom` is a message whose `update` panics on purpose. A demo does not need
# one; the boundary does, because a guest that cannot survive a failing
# application is a guest that loses the model on the first bug. It is what
# scripts/wasm-dom-contract points at the wasm failure runtime.

use tea_core/app.{App, update, view}
use tea_core/diff.{Patch, Replace, AppendKids, TruncateKids, diff}
use tea_dom/node.{Node, Text, Elem}
use tea_dom/dsl.{button, div, el, text}

## The whole model: how many times the user has pressed something, net.
pub type Counter = { n: Int }

## What the four buttons mean.
pub type Msg =
  | Inc
  | Dec
  | Reset
  | Boom
derive Show

## The model a fresh page starts from.
pub fn init() -> Counter = Counter { n: 0 }

impl App[Counter] {
  type Msg = Msg
  type View = Node[Msg]

  fn update(m: Counter, msg: Msg) -> Counter =
    match msg {
      Inc -> Counter { n: m.n + 1 }
      Dec -> Counter { n: m.n - 1 }
      Reset -> init()
      # Deliberate, and the only unhandled failure in this tree. `tea_dom`'s
      # `serve` catches it at the boundary and answers with an error reply;
      # on wasm that landing is the A1 shadow stack.
      Boom -> panic("boom: update failed on purpose at n=" ++ to_string(m.n))
    }

  fn view(m: Counter) -> Node[Msg] = {
    let heading: Node[Msg] = el("h1", kids: [text("dawn counter")])
    let count: Node[Msg] = el("p", class: "count", kids: [text(to_string(m.n))])
    let bar: Node[Msg] = div(class: "bar", kids: ticks(m.n))
    let controls: Node[Msg] =
      div(class: "row", kids: [
        button("-", Dec),
        button("+", Inc),
        button("reset", Reset),
        button("boom", Boom),
      ])
    div(class: if m.n < 0 { "counter below-zero" } else { "counter" },
      kids: [heading, count, bar, controls])
  }
}

## One child, in the order the body wants it.
##
## The collector vocabulary lives in this module rather than in
## `packages/tea-dom`, which is where a DSL word belongs until the shape has
## been through a release: the package ships constructors, and a way of
## *building* a child list is a second thing.
effect Emit {
  fn emit(w: Node[Msg]) -> Unit
}

## Run `body` and answer everything it emitted, in the order it emitted it.
##
## The cell is the collector. It is reachable from the arm that writes it and
## from the line below that reads it, and from nowhere else, so `collect` is a
## pure function whose row says nothing about `Emit`: the handler is what
## takes the label off (spec §6.5).
fn collect(body: fn() -> Unit !Emit) -> List[Node[Msg]] = {
  with handle Emit {
    var acc: List[Node[Msg]] = []
    emit(w) => { acc = acc ++ [w] }
  }
  body()
  acc
}

## One `span` per unit of a positive count, and none at all below zero.
##
## This is the part of the view whose *child list length* depends on the
## model, and it is here for the reconciler rather than for the user: without
## it the app only ever produces `replace` and `set-self`, and the two tail
## ops (`append`, `truncate`) would never cross the boundary in a running
## program. Counting up appends one span, counting down truncates one, and
## the spans already on screen are never mentioned.
##
## Written as a loop that emits, which is what the accumulator recursion this
## used to be was standing in for. A negative count is an empty range rather
## than a base case, so "none at all below zero" is the loop not running.
fn ticks(n: Int) -> List[Node[Msg]] =
  collect(() => {
    for _i in 0..n {
      emit(tick())
    }
  })

fn tick() -> Node[Msg] = el("span", class: "tick")

## A model as text, and back. Opaque to everything but this module: the wire
## carries it as a JSON string and the host hands it back untouched.
pub fn encode(m: Counter) -> String = to_string(m.n)

## Total, because a host may hand back anything: an unreadable model reads as
## the initial one rather than as a panic, which keeps a corrupted page
## recoverable by clicking.
pub fn decode(s: String) -> Counter =
  match parse_int(s) {
    Some(n) -> Counter { n: n }
    None -> init()
  }

test "update folds messages and the model round-trips through its text" {
  let m = update(update(init(), Inc), Inc)
  assert m == Counter { n: 2 }
  assert update(m, Dec) == Counter { n: 1 }
  assert update(m, Reset) == init()
  assert decode(encode(m)) == m
  assert decode(encode(Counter { n: -17 })) == Counter { n: -17 }
  assert decode("not a number") == init()
}

test "the view is a value, so a frame is one assertion" {
  let want: Node[Msg] =
    div(class: "counter", kids: [
      el("h1", kids: [text("dawn counter")]),
      el("p", class: "count", kids: [text("0")]),
      div(class: "bar"),
      div(class: "row", kids: [
        button("-", Dec),
        button("+", Inc),
        button("reset", Reset),
        button("boom", Boom),
      ]),
    ])
  assert view(init()) == want
}

test "the bar is the only child list whose length the model decides" {
  # The two tail ops exist because of this: counting up is one `append` at
  # the bar and one `replace` at the count, and the spans already there are
  # not mentioned. Below zero the bar is empty rather than negative.
  let none: List[Node[Msg]] = []
  assert ticks(0) == none
  assert ticks(-3) == none
  assert len(ticks(3)) == 3
  assert ticks(3) == [tick(), tick(), tick()]
  let two: List[Patch[Node[Msg]]] = diff(view(Counter { n: 1 }), view(Counter { n: 2 }))
  assert len(two) == 2
  assert two[0] == Patch { path: [1, 0], op: Replace(w: text("2")) }
  assert two[1] == Patch { path: [2], op: AppendKids(ws: [tick()]) }
  let back: List[Patch[Node[Msg]]] = diff(view(Counter { n: 2 }), view(Counter { n: 1 }))
  assert back[1] == Patch { path: [2], op: TruncateKids(keep: 1) }
}

test "the root's class is the only thing the sign of the count changes" {
  let below = view(Counter { n: -1 })
  match below {
    Text(_) -> { assert false }
    Elem(_, props, ..) -> { assert props == [("class", "counter below-zero")] }
  }
  match view(Counter { n: 0 }) {
    Text(_) -> { assert false }
    Elem(_, props, ..) -> { assert props == [("class", "counter")] }
  }
}