tea_todo

An interactive todo list built on packages/tea-core and packages/tea-term, the Elm architecture v1: the model, messages, update and view live in todo.dawn as pure functions. Since renderer knife 3 the io loop lives in the library too (tea_term/runtime.run, incremental repaint included), so this file is down to naming the app's pure hooks: which tree to paint, how a line becomes a message, which ticks the model wants (none), and when to stop.

examples/projects/tea_todo/src/main.dawn

# An interactive todo list built on packages/tea-core and packages/tea-term,
# the Elm architecture v1:
# the model, messages, update and view live in todo.dawn as pure functions.
# Since renderer knife 3 the io loop lives in the library too
# (tea_term/runtime.run, incremental repaint included), so this file is down
# to naming the app's pure hooks: which tree to paint, how a line becomes a
# message, which ticks the model wants (none), and when to stop.
#
# Commands: `add <title>`, `tog <id>`, `del <id>`, `press <n>` (push the
# n-th button of the current view), `quit` (or `q`).

use tea_term/runtime.{run}
use tea_core/sub.{Sub}
use tea_core/app.{view}
use todo.{Model, Msg, initial, parse_cmd}

pub fn main() -> Unit !io =
  run(initial(), m => view(m), (m, line) => parse_cmd(m, line), m => no_ticks(m), m => m.quit)

fn no_ticks(m: Model) -> List[Sub[Msg]] = []

examples/projects/tea_todo/src/todo.dawn

# The pure half of the todo app: model, messages, `update`, `view`, and the
# line parser. Everything here is a value-in, value-out function, which is what
# the test blocks below lean on -- an interaction is asserted as data, no
# terminal in sight. The io shell around it is main.dawn.

use std/list
use std/str
use tea_term/route
use tea_core/app.{App, update, view}
use tea_term/widget.{Widget, Text, Styled, Row, Column, Button, Bold, Dim}
use tea_term/dsl.{text, bold, dim, button, row, column}

pub type Todo = { id: Int, title: String, done: Bool }

## `note` is the feedback line of the next frame ("added #3", "no todo #9");
## `quit` is a model flag rather than a driver special case, so that quitting
## stays a message like everything else.
pub type Model = { todos: List[Todo], next_id: Int, note: String, quit: Bool }

pub type Msg =
  | Add(title: String)
  | Toggle(id: Int)
  | Del(id: Int)
  | Quit
  | Bad(input: String)
derive Show

pub fn initial() -> Model = Model { todos: [], next_id: 1, note: "", quit: false }

## One command line -> one message. Unknown or malformed input becomes `Bad`,
## so the parser is total and `update` owns the wording of the complaint.
pub fn parse_line(line: String) -> Msg = {
  let t = str.trim(line)
  if t == "quit" || t == "q" {
    Quit
  } else {
    match str.split_once(t, " ") {
      Some((cmd, rest)) -> {
        let arg = str.trim(rest)
        if cmd == "add" && arg != "" {
          Add(title: arg)
        } else if cmd == "tog" {
          id_msg(arg, id => Toggle(id: id), t)
        } else if cmd == "del" {
          id_msg(arg, id => Del(id: id), t)
        } else {
          Bad(input: t)
        }
      }
      None -> Bad(input: t)
    }
  }
}

## Model-aware command parsing: `press <n>` pushes the n-th button of the
## *current* view (pre-order, 1-based), everything else is `parse_line`.
## Routing through the tree is the point: the checkbox of row n carries a
## `Toggle` for that row's id, so `press` reaches the same message a click
## would mean, without this parser knowing what buttons exist.
pub fn parse_cmd(m: Model, line: String) -> Msg = {
  let t = str.trim(line)
  match str.split_once(t, " ") {
    Some((cmd, rest)) ->
      if cmd == "press" {
        match parse_int(str.trim(rest)) {
          Some(n) ->
            match route.press(view(m), n) {
              Some(msg) -> msg
              None -> Bad(input: t)
            }
          None -> Bad(input: t)
        }
      } else {
        parse_line(line)
      }
    None -> parse_line(line)
  }
}

fn id_msg(arg: String, make: fn(Int) -> Msg, whole: String) -> Msg =
  match parse_int(arg) {
    Some(id) -> make(id)
    None -> Bad(input: whole)
  }

fn has_id(m: Model, id: Int) -> Bool = list.any(m.todos, t => t.id == id)

fn note_only(m: Model, note: String) -> Model = Model { ..m, note: note }

impl App[Model] {
  type Msg = Msg
  type View = Widget[Msg]
  fn update(m: Model, msg: Msg) -> Model =
    match msg {
      Add(title) -> Model {
        ..m,
        todos: m.todos ++ [Todo { id: m.next_id, title: title, done: false }],
        next_id: m.next_id + 1,
        note: "added #" ++ to_string(m.next_id),
      }
      Toggle(id) ->
        if has_id(m, id) {
          Model {
            ..m,
            todos: list.map(m.todos, t =>
                if t.id == id { Todo { ..t, done: not t.done } } else { t }),
            note: "",
          }
        } else {
          note_only(m, "no todo #" ++ to_string(id))
        }
      Del(id) ->
        if has_id(m, id) {
          Model {
            ..m,
            todos: list.filter(m.todos, t => t.id != id),
            note: "deleted #" ++ to_string(id),
          }
        } else {
          note_only(m, "no todo #" ++ to_string(id))
        }
      Quit -> Model { ..m, note: "bye", quit: true }
      Bad(input) ->
        note_only(m, if input == "" { "type a command" } else { "unknown command: " ++ input })
    }

  # The view writes in the DSL spelling (tea/dsl) since #310; the snapshot
  # test below keeps a bare-constructor twin of the same tree, so the two
  # surfaces are held equal by `==`, not by trust.
  #
  # The children are one list literal, spreads and all (spec §4.11). It used to
  # be `[header] ++ body ++ note ++ [help]` over two annotated `let`s, and the
  # annotations were not decoration: `[dim(text("nothing yet..."))]` and
  # `[text(m.note)]` are lists of a `Widget[M]` whose `M` appears nowhere in
  # the arguments, so neither could be typed as a binding of its own. Inside
  # the literal they are typed by their siblings -- `header` settles `M`, and
  # the element type reaches through `..` and into the `if` body from there.
  fn view(m: Model) -> Widget[Msg] = {
    let done = list.filter(m.todos, t => t.done)
    let header: Widget[Msg] = bold(text(
      "todo (" ++ to_string(len(done)) ++ "/" ++ to_string(len(m.todos)) ++ " done)"))
    column([
      header,
      ..if list.is_empty(m.todos) {
        [dim(text("nothing yet; try: add buy milk"))]
      } else {
        list.map(m.todos, t => item_row(t))
      },
      if m.note != "" { text(m.note) },
      dim(text("commands: add <title> | tog <id> | del <id> | press <n> | quit")),
    ])
  }
}

## One todo as one row. The checkbox is a Button carrying the Toggle message:
## the tree records what clicking would mean even though the line-mode driver
## reaches the same message through `tog <id>` or `press <n>`.
fn item_row(t: Todo) -> Widget[Msg] =
  row([
    button(if t.done { "x" } else { " " }, Toggle(id: t.id)),
    text("#" ++ to_string(t.id)),
    if t.done { dim(text(t.title)) } else { text(t.title) },
  ])

test "add assigns increasing ids and never recycles them" {
  let m = update(update(initial(), Add(title: "one")), Add(title: "two"))
  assert list.map(m.todos, t => t.id) == [1, 2]
  let m2 = update(update(m, Del(id: 2)), Add(title: "three"))
  assert list.map(m2.todos, t => t.id) == [1, 3]
}

test "toggle flips exactly the named todo, twice is identity" {
  let m = update(update(initial(), Add(title: "a")), Add(title: "b"))
  let once = update(m, Toggle(id: 2))
  assert list.map(once.todos, t => t.done) == [false, true]
  let twice = update(once, Toggle(id: 2))
  assert list.map(twice.todos, t => t.done) == [false, false]
}

test "missing ids and bad input become notes, not changes" {
  let m = update(initial(), Add(title: "a"))
  let miss = update(m, Toggle(id: 9))
  assert miss.todos == m.todos
  assert miss.note == "no todo #9"
  let bad = update(m, Bad(input: "frobnicate"))
  assert bad.todos == m.todos
  assert bad.note == "unknown command: frobnicate"
  assert not bad.quit
}

test "quit is a message and a model flag, not a driver special case" {
  let m = update(initial(), Quit)
  assert m.quit
  assert m.note == "bye"
}

test "parse_line is total over junk" {
  assert parse_line("add buy milk") == Add(title: "buy milk")
  assert parse_line("  tog 2 ") == Toggle(id: 2)
  assert parse_line("del 10") == Del(id: 10)
  assert parse_line("q") == Quit
  assert parse_line("tog two") == Bad(input: "tog two")
  assert parse_line("add ") == Bad(input: "add")
  assert parse_line("") == Bad(input: "")
}

test "press routes through the current view's buttons" {
  let m = update(update(initial(), Add(title: "a")), Add(title: "b"))
  assert parse_cmd(m, "press 1") == Toggle(id: 1)
  assert parse_cmd(m, "press 2") == Toggle(id: 2)
  assert parse_cmd(m, "press 9") == Bad(input: "press 9")
  assert parse_cmd(m, "press x") == Bad(input: "press x")
  assert parse_cmd(m, "add c") == Add(title: "c")
  assert parse_cmd(m, "q") == Quit
}

test "press numbers rows, not ids: after a deletion they diverge" {
  let m = update(update(update(initial(), Add(title: "a")), Add(title: "b")), Del(id: 1))
  assert parse_cmd(m, "press 1") == Toggle(id: 2)
}

test "view snapshot: the tree is data and compares with ==" {
  let m = update(update(update(initial(), Add(title: "buy milk")), Add(title: "ship tea")), Toggle(id: 1))
  let want: Widget[Msg] = Column(kids: [
    Styled(style: Bold, child: Text(s: "todo (1/2 done)")),
    Row(kids: [
      Button(label: "x", on_press: Toggle(id: 1)),
      Text(s: "#1"),
      Styled(style: Dim, child: Text(s: "buy milk")),
    ]),
    Row(kids: [
      Button(label: " ", on_press: Toggle(id: 2)),
      Text(s: "#2"),
      Text(s: "ship tea"),
    ]),
    Styled(style: Dim, child: Text(s: "commands: add <title> | tog <id> | del <id> | press <n> | quit")),
  ])
  assert view(m) == want
}

test "view of the empty model equals its bare-constructor twin too" {
  let want: Widget[Msg] = Column(kids: [
    Styled(style: Bold, child: Text(s: "todo (0/0 done)")),
    Styled(style: Dim, child: Text(s: "nothing yet; try: add buy milk")),
    Styled(style: Dim, child: Text(s:
      "commands: add <title> | tog <id> | del <id> | press <n> | quit")),
  ])
  assert view(initial()) == want
}