about summary refs log tree commit diff stats
path: root/archive/2.vm/edit
diff options
context:
space:
mode:
Diffstat (limited to 'archive/2.vm/edit')
-rw-r--r--archive/2.vm/edit/001-editor.mu464
-rw-r--r--archive/2.vm/edit/002-typing.mu1144
-rw-r--r--archive/2.vm/edit/003-shortcuts.mu4462
-rw-r--r--archive/2.vm/edit/004-programming-environment.mu549
-rw-r--r--archive/2.vm/edit/005-sandbox.mu1193
-rw-r--r--archive/2.vm/edit/006-sandbox-copy.mu395
-rw-r--r--archive/2.vm/edit/007-sandbox-delete.mu342
-rw-r--r--archive/2.vm/edit/008-sandbox-edit.mu325
-rw-r--r--archive/2.vm/edit/009-sandbox-test.mu231
-rw-r--r--archive/2.vm/edit/010-sandbox-trace.mu253
-rw-r--r--archive/2.vm/edit/011-errors.mu886
-rw-r--r--archive/2.vm/edit/012-editor-undo.mu2111
-rw-r--r--archive/2.vm/edit/Readme.md49
13 files changed, 12404 insertions, 0 deletions
diff --git a/archive/2.vm/edit/001-editor.mu b/archive/2.vm/edit/001-editor.mu
new file mode 100644
index 00000000..b3399dbb
--- /dev/null
+++ b/archive/2.vm/edit/001-editor.mu
@@ -0,0 +1,464 @@
+## the basic editor data structure, and how it displays text to the screen
+
+# temporary main for this layer: just render the given text at the given
+# screen dimensions, then stop
+def main text:text [
+  local-scope
+  load-inputs
+  open-console
+  clear-screen null/screen  # non-scrolling app
+  e:&:editor <- new-editor text, 0/left, 5/right
+  render null/screen, e
+  wait-for-event null/console
+  close-console
+]
+
+scenario editor-renders-text-to-screen [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    # top line of screen reserved for menu
+    .          .
+    .abc       .
+    .          .
+  ]
+]
+
+container editor [
+  # editable text: doubly linked list of characters (head contains a special sentinel)
+  data:&:duplex-list:char
+  top-of-screen:&:duplex-list:char
+  bottom-of-screen:&:duplex-list:char
+  # location before cursor inside data
+  before-cursor:&:duplex-list:char
+
+  # raw bounds of display area on screen
+  # always displays from row 1 (leaving row 0 for a menu) and at most until bottom of screen
+  left:num
+  right:num
+  bottom:num
+  # raw screen coordinates of cursor
+  cursor-row:num
+  cursor-column:num
+]
+
+# creates a new editor widget
+#   right is exclusive
+def new-editor s:text, left:num, right:num -> result:&:editor [
+  local-scope
+  load-inputs
+  # no clipping of bounds
+  right <- subtract right, 1
+  result <- new editor:type
+  # initialize screen-related fields
+  *result <- put *result, left:offset, left
+  *result <- put *result, right:offset, right
+  # initialize cursor coordinates
+  *result <- put *result, cursor-row:offset, 1/top
+  *result <- put *result, cursor-column:offset, left
+  # initialize empty contents
+  init:&:duplex-list:char <- push 167/§, null
+  *result <- put *result, data:offset, init
+  *result <- put *result, top-of-screen:offset, init
+  *result <- put *result, before-cursor:offset, init
+  result <- insert-text result, s
+  <editor-initialization>
+]
+
+def insert-text editor:&:editor, text:text -> editor:&:editor [
+  local-scope
+  load-inputs
+  curr:&:duplex-list:char <- get *editor, data:offset
+  insert curr, text
+]
+
+scenario editor-initializes-without-data [
+  local-scope
+  assume-screen 5/width, 3/height
+  run [
+    e:&:editor <- new-editor null/data, 2/left, 5/right
+    1:editor/raw <- copy *e
+  ]
+  memory-should-contain [
+    # 1,2 (data) <- just the § sentinel
+    # 3,4 (top of screen) <- the § sentinel
+    # 5 (bottom of screen) <- null since text fits on screen
+    5 <- 0
+    6 <- 0
+    # 7,8 (before cursor) <- the § sentinel
+    9 <- 2  # left
+    10 <- 4  # right  (inclusive)
+    11 <- 0  # bottom (not set until render)
+    12 <- 1  # cursor row
+    13 <- 2  # cursor column
+  ]
+  screen-should-contain [
+    .     .
+    .     .
+    .     .
+  ]
+]
+
+# Assumes cursor should be at coordinates (cursor-row, cursor-column) and
+# updates before-cursor to match. Might also move coordinates if they're
+# outside text.
+def render screen:&:screen, editor:&:editor -> last-row:num, last-column:num, screen:&:screen, editor:&:editor [
+  local-scope
+  load-inputs
+  return-unless editor, 1/top, 0/left
+  left:num <- get *editor, left:offset
+  screen-height:num <- screen-height screen
+  right:num <- get *editor, right:offset
+  # traversing editor
+  curr:&:duplex-list:char <- get *editor, top-of-screen:offset
+  prev:&:duplex-list:char <- copy curr  # just in case curr becomes null and we can't compute prev
+  curr <- next curr
+  # traversing screen
+  color:num <- copy 7/white
+  row:num <- copy 1/top
+  column:num <- copy left
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  screen <- move-cursor screen, row, column
+  {
+    +next-character
+    break-unless curr
+    off-screen?:bool <- greater-or-equal row, screen-height
+    break-if off-screen?
+    # update editor.before-cursor
+    # Doing so at the start of each iteration ensures it stays one step behind
+    # the current character.
+    {
+      at-cursor-row?:bool <- equal row, cursor-row
+      break-unless at-cursor-row?
+      at-cursor?:bool <- equal column, cursor-column
+      break-unless at-cursor?
+      before-cursor <- copy prev
+    }
+    c:char <- get *curr, value:offset
+    <character-c-received>
+    {
+      # newline? move to left rather than 0
+      newline?:bool <- equal c, 10/newline
+      break-unless newline?
+      # adjust cursor if necessary
+      {
+        at-cursor-row?:bool <- equal row, cursor-row
+        break-unless at-cursor-row?
+        left-of-cursor?:bool <- lesser-than column, cursor-column
+        break-unless left-of-cursor?
+        cursor-column <- copy column
+        before-cursor <- prev curr
+      }
+      # clear rest of line in this window
+      clear-line-until screen, right
+      # skip to next line
+      row <- add row, 1
+      column <- copy left
+      screen <- move-cursor screen, row, column
+      curr <- next curr
+      prev <- next prev
+      loop +next-character
+    }
+    {
+      # at right? wrap. even if there's only one more letter left; we need
+      # room for clicking on the cursor after it.
+      at-right?:bool <- equal column, right
+      break-unless at-right?
+      # print wrap icon
+      wrap-icon:char <- copy 8617/loop-back-to-left
+      print screen, wrap-icon, 245/grey
+      column <- copy left
+      row <- add row, 1
+      screen <- move-cursor screen, row, column
+      # don't increment curr
+      loop +next-character
+    }
+    print screen, c, color
+    curr <- next curr
+    prev <- next prev
+    column <- add column, 1
+    loop
+  }
+  # save first character off-screen
+  *editor <- put *editor, bottom-of-screen:offset, curr
+  # is cursor to the right of the last line? move to end
+  {
+    at-cursor-row?:bool <- equal row, cursor-row
+    cursor-outside-line?:bool <- lesser-or-equal column, cursor-column
+    before-cursor-on-same-line?:bool <- and at-cursor-row?, cursor-outside-line?
+    above-cursor-row?:bool <- lesser-than row, cursor-row
+    before-cursor?:bool <- or before-cursor-on-same-line?, above-cursor-row?
+    break-unless before-cursor?
+    cursor-row <- copy row
+    cursor-column <- copy column
+    before-cursor <- copy prev
+  }
+  *editor <- put *editor, bottom:offset, row
+  *editor <- put *editor, cursor-row:offset, cursor-row
+  *editor <- put *editor, cursor-column:offset, cursor-column
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  clear-line-until screen, right
+  row <- add row, 1
+  return row, left/column
+]
+
+def clear-screen-from screen:&:screen, row:num, column:num, left:num, right:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  # if it's the real screen, use the optimized primitive
+  {
+    break-if screen
+    clear-display-from row, column, left, right
+    return
+  }
+  # if not, go the slower route
+  screen <- move-cursor screen, row, column
+  clear-line-until screen, right
+  clear-rest-of-screen screen, row, left, right
+]
+
+def clear-rest-of-screen screen:&:screen, row:num, left:num, right:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  row <- add row, 1
+  # if it's the real screen, use the optimized primitive
+  {
+    break-if screen
+    clear-display-from row, left, left, right
+    return
+  }
+  screen <- move-cursor screen, row, left
+  screen-height:num <- screen-height screen
+  {
+    at-bottom-of-screen?:bool <- greater-or-equal row, screen-height
+    break-if at-bottom-of-screen?
+    screen <- move-cursor screen, row, left
+    clear-line-until screen, right
+    row <- add row, 1
+    loop
+  }
+]
+
+scenario editor-prints-multiple-lines [
+  local-scope
+  assume-screen 5/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .     .
+    .abc  .
+    .def  .
+    .     .
+  ]
+]
+
+scenario editor-handles-offsets [
+  local-scope
+  assume-screen 5/width, 5/height
+  e:&:editor <- new-editor [abc], 1/left, 5/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .     .
+    . abc .
+    .     .
+  ]
+]
+
+scenario editor-prints-multiple-lines-at-offset [
+  local-scope
+  assume-screen 5/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 1/left, 5/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .     .
+    . abc .
+    . def .
+    .     .
+  ]
+]
+
+scenario editor-wraps-long-lines [
+  local-scope
+  assume-screen 5/width, 5/height
+  e:&:editor <- new-editor [abc def], 0/left, 5/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .     .
+    .abc ↩.
+    .def  .
+    .     .
+  ]
+  screen-should-contain-in-color 245/grey [
+    .     .
+    .    ↩.
+    .     .
+    .     .
+  ]
+]
+
+scenario editor-wraps-barely-long-lines [
+  local-scope
+  assume-screen 5/width, 5/height
+  e:&:editor <- new-editor [abcde], 0/left, 5/right
+  run [
+    render screen, e
+  ]
+  # still wrap, even though the line would fit. We need room to click on the
+  # end of the line
+  screen-should-contain [
+    .     .
+    .abcd↩.
+    .e    .
+    .     .
+  ]
+  screen-should-contain-in-color 245/grey [
+    .     .
+    .    ↩.
+    .     .
+    .     .
+  ]
+]
+
+scenario editor-with-empty-text [
+  local-scope
+  assume-screen 5/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 5/right
+  run [
+    render screen, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .     .
+    .     .
+    .     .
+  ]
+  memory-should-contain [
+    3 <- 1  # cursor row
+    4 <- 0  # cursor column
+  ]
+]
+
+# just a little color for Mu code
+
+scenario render-colors-comments [
+  local-scope
+  assume-screen 5/width, 5/height
+  s:text <- new [abc
+# de
+f]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .     .
+    .abc  .
+    .# de .
+    .f    .
+    .     .
+  ]
+  screen-should-contain-in-color 12/lightblue, [
+    .     .
+    .     .
+    .# de .
+    .     .
+    .     .
+  ]
+  screen-should-contain-in-color 7/white, [
+    .     .
+    .abc  .
+    .     .
+    .f    .
+    .     .
+  ]
+]
+
+after <character-c-received> [
+  color <- get-color color, c
+]
+
+# so far the previous color is all the information we need; that may change
+def get-color color:num, c:char -> color:num [
+  local-scope
+  load-inputs
+  color-is-white?:bool <- equal color, 7/white
+  # if color is white and next character is '#', switch color to blue
+  {
+    break-unless color-is-white?
+    starting-comment?:bool <- equal c, 35/#
+    break-unless starting-comment?
+    trace 90, [app], [switch color back to blue]
+    return 12/lightblue
+  }
+  # if color is blue and next character is newline, switch color to white
+  {
+    color-is-blue?:bool <- equal color, 12/lightblue
+    break-unless color-is-blue?
+    ending-comment?:bool <- equal c, 10/newline
+    break-unless ending-comment?
+    trace 90, [app], [switch color back to white]
+    return 7/white
+  }
+  # if color is white (no comments) and next character is '<', switch color to red
+  {
+    break-unless color-is-white?
+    starting-assignment?:bool <- equal c, 60/<
+    break-unless starting-assignment?
+    return 1/red
+  }
+  # if color is red and next character is space, switch color to white
+  {
+    color-is-red?:bool <- equal color, 1/red
+    break-unless color-is-red?
+    ending-assignment?:bool <- equal c, 32/space
+    break-unless ending-assignment?
+    return 7/white
+  }
+  # otherwise no change
+  return color
+]
+
+scenario render-colors-assignment [
+  local-scope
+  assume-screen 8/width, 5/height
+  s:text <- new [abc
+d <- e
+f]
+  e:&:editor <- new-editor s, 0/left, 8/right
+  run [
+    render screen, e
+  ]
+  screen-should-contain [
+    .        .
+    .abc     .
+    .d <- e  .
+    .f       .
+    .        .
+  ]
+  screen-should-contain-in-color 1/red, [
+    .        .
+    .        .
+    .  <-    .
+    .        .
+    .        .
+  ]
+]
diff --git a/archive/2.vm/edit/002-typing.mu b/archive/2.vm/edit/002-typing.mu
new file mode 100644
index 00000000..ef3f25d2
--- /dev/null
+++ b/archive/2.vm/edit/002-typing.mu
@@ -0,0 +1,1144 @@
+## handling events from the keyboard, mouse, touch screen, ...
+
+# temporary main: interactive editor
+# hit ctrl-c to exit
+def! main text:text [
+  local-scope
+  load-inputs
+  open-console
+  clear-screen null/screen  # non-scrolling app
+  editor:&:editor <- new-editor text, 5/left, 45/right
+  editor-render null/screen, editor
+  editor-event-loop null/screen, null/console, editor
+  close-console
+]
+
+def editor-event-loop screen:&:screen, console:&:console, editor:&:editor -> screen:&:screen, console:&:console, editor:&:editor [
+  local-scope
+  load-inputs
+  {
+    # looping over each (keyboard or touch) event as it occurs
+    +next-event
+    cursor-row:num <- get *editor, cursor-row:offset
+    cursor-column:num <- get *editor, cursor-column:offset
+    screen <- move-cursor screen, cursor-row, cursor-column
+    e:event, found?:bool, quit?:bool, console <- read-event console
+    loop-unless found?
+    break-if quit?  # only in tests
+    trace 10, [app], [next-event]
+    # 'touch' event
+    t:touch-event, is-touch?:bool <- maybe-convert e, touch:variant
+    {
+      break-unless is-touch?
+      move-cursor editor, screen, t
+      loop +next-event
+    }
+    # keyboard events
+    {
+      break-if is-touch?
+      go-render?:bool <- handle-keyboard-event screen, editor, e
+      {
+        break-unless go-render?
+        screen <- editor-render screen, editor
+      }
+    }
+    loop
+  }
+]
+
+# process click, return if it was on current editor
+def move-cursor editor:&:editor, screen:&:screen, t:touch-event -> in-focus?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  return-unless editor, false
+  click-row:num <- get t, row:offset
+  return-unless click-row, false  # ignore clicks on 'menu'
+  click-column:num <- get t, column:offset
+  left:num <- get *editor, left:offset
+  too-far-left?:bool <- lesser-than click-column, left
+  return-if too-far-left?, false
+  right:num <- get *editor, right:offset
+  too-far-right?:bool <- greater-than click-column, right
+  return-if too-far-right?, false
+  # position cursor
+  <begin-move-cursor>
+  editor <- snap-cursor editor, screen, click-row, click-column
+  undo-coalesce-tag:num <- copy 0/never
+  <end-move-cursor>
+  # gain focus
+  return true
+]
+
+# Variant of 'render' that only moves the cursor (coordinates and
+# before-cursor). If it's past the end of a line, it 'slides' it left. If it's
+# past the last line it positions at end of last line.
+def snap-cursor editor:&:editor, screen:&:screen, target-row:num, target-column:num -> editor:&:editor [
+  local-scope
+  load-inputs
+  return-unless editor
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  screen-height:num <- screen-height screen
+  # count newlines until screen row
+  curr:&:duplex-list:char <- get *editor, top-of-screen:offset
+  prev:&:duplex-list:char <- copy curr  # just in case curr becomes null and we can't compute prev
+  curr <- next curr
+  row:num <- copy 1/top
+  column:num <- copy left
+  *editor <- put *editor, cursor-row:offset, target-row
+  cursor-row:num <- copy target-row
+  *editor <- put *editor, cursor-column:offset, target-column
+  cursor-column:num <- copy target-column
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  {
+    +next-character
+    break-unless curr
+    off-screen?:bool <- greater-or-equal row, screen-height
+    break-if off-screen?
+    # update editor.before-cursor
+    # Doing so at the start of each iteration ensures it stays one step behind
+    # the current character.
+    {
+      at-cursor-row?:bool <- equal row, cursor-row
+      break-unless at-cursor-row?
+      at-cursor?:bool <- equal column, cursor-column
+      break-unless at-cursor?
+      before-cursor <- copy prev
+      *editor <- put *editor, before-cursor:offset, before-cursor
+    }
+    c:char <- get *curr, value:offset
+    {
+      # newline? move to left rather than 0
+      newline?:bool <- equal c, 10/newline
+      break-unless newline?
+      # adjust cursor if necessary
+      {
+        at-cursor-row?:bool <- equal row, cursor-row
+        break-unless at-cursor-row?
+        left-of-cursor?:bool <- lesser-than column, cursor-column
+        break-unless left-of-cursor?
+        cursor-column <- copy column
+        *editor <- put *editor, cursor-column:offset, cursor-column
+        before-cursor <- copy prev
+        *editor <- put *editor, before-cursor:offset, before-cursor
+      }
+      # skip to next line
+      row <- add row, 1
+      column <- copy left
+      curr <- next curr
+      prev <- next prev
+      loop +next-character
+    }
+    {
+      # at right? wrap. even if there's only one more letter left; we need
+      # room for clicking on the cursor after it.
+      at-right?:bool <- equal column, right
+      break-unless at-right?
+      column <- copy left
+      row <- add row, 1
+      # don't increment curr/prev
+      loop +next-character
+    }
+    curr <- next curr
+    prev <- next prev
+    column <- add column, 1
+    loop
+  }
+  # is cursor to the right of the last line? move to end
+  {
+    at-cursor-row?:bool <- equal row, cursor-row
+    cursor-outside-line?:bool <- lesser-or-equal column, cursor-column
+    before-cursor-on-same-line?:bool <- and at-cursor-row?, cursor-outside-line?
+    above-cursor-row?:bool <- lesser-than row, cursor-row
+    before-cursor?:bool <- or before-cursor-on-same-line?, above-cursor-row?
+    break-unless before-cursor?
+    cursor-row <- copy row
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- copy column
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    before-cursor <- copy prev
+    *editor <- put *editor, before-cursor:offset, before-cursor
+  }
+]
+
+# Process an event 'e' and try to minimally update the screen.
+# Set 'go-render?' to true to indicate the caller must perform a non-minimal update.
+def handle-keyboard-event screen:&:screen, editor:&:editor, e:event -> go-render?:bool, screen:&:screen, editor:&:editor [
+  local-scope
+  load-inputs
+  return-unless editor, false/don't-render
+  screen-width:num <- screen-width screen
+  screen-height:num <- screen-height screen
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  save-row:num <- copy cursor-row
+  save-column:num <- copy cursor-column
+  # character
+  {
+    c:char, is-unicode?:bool <- maybe-convert e, text:variant
+    break-unless is-unicode?
+    trace 10, [app], [handle-keyboard-event: special character]
+    # exceptions for special characters go here
+    <handle-special-character>
+    # ignore any other special characters
+    regular-character?:bool <- greater-or-equal c, 32/space
+    return-unless regular-character?, false/don't-render
+    # otherwise type it in
+    <begin-insert-character>
+    go-render? <- insert-at-cursor editor, c, screen
+    <end-insert-character>
+    return
+  }
+  # special key to modify the text or move the cursor
+  k:num, is-keycode?:bool <- maybe-convert e:event, keycode:variant
+  assert is-keycode?, [event was of unknown type; neither keyboard nor mouse]
+  # handlers for each special key will go here
+  <handle-special-key>
+  return true/go-render
+]
+
+def insert-at-cursor editor:&:editor, c:char, screen:&:screen -> go-render?:bool, editor:&:editor, screen:&:screen [
+  local-scope
+  load-inputs
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  insert c, before-cursor
+  before-cursor <- next before-cursor
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  save-row:num <- copy cursor-row
+  save-column:num <- copy cursor-column
+  screen-width:num <- screen-width screen
+  screen-height:num <- screen-height screen
+  # occasionally we'll need to mess with the cursor
+  <insert-character-special-case>
+  # but mostly we'll just move the cursor right
+  cursor-column <- add cursor-column, 1
+  *editor <- put *editor, cursor-column:offset, cursor-column
+  next:&:duplex-list:char <- next before-cursor
+  {
+    # at end of all text? no need to scroll? just print the character and leave
+    at-end?:bool <- equal next, null
+    break-unless at-end?
+    bottom:num <- subtract screen-height, 1
+    at-bottom?:bool <- equal save-row, bottom
+    at-right?:bool <- equal save-column, right
+    overflow?:bool <- and at-bottom?, at-right?
+    break-if overflow?
+    move-cursor screen, save-row, save-column
+    print screen, c
+    return false/don't-render
+  }
+  {
+    # not at right margin? print the character and rest of line
+    break-unless next
+    at-right?:bool <- greater-or-equal cursor-column, screen-width
+    break-if at-right?
+    curr:&:duplex-list:char <- copy before-cursor
+    move-cursor screen, save-row, save-column
+    curr-column:num <- copy save-column
+    {
+      # hit right margin? give up and let caller render
+      at-right?:bool <- greater-than curr-column, right
+      return-if at-right?, true/go-render
+      break-unless curr
+      # newline? done.
+      currc:char <- get *curr, value:offset
+      at-newline?:bool <- equal currc, 10/newline
+      break-if at-newline?
+      print screen, currc
+      curr-column <- add curr-column, 1
+      curr <- next curr
+      loop
+    }
+    return false/don't-render
+  }
+  return true/go-render
+]
+
+# helper for tests
+def editor-render screen:&:screen, editor:&:editor -> screen:&:screen, editor:&:editor [
+  local-scope
+  load-inputs
+  old-top-idx:num <- save-top-idx screen
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  row:num, column:num <- render screen, editor
+  draw-horizontal screen, row, left, right, 9480/horizontal-dotted
+  row <- add row, 1
+  clear-screen-from screen, row, left, left, right
+  assert-no-scroll screen, old-top-idx
+]
+
+scenario editor-handles-empty-event-queue [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  assume-console []
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-handles-mouse-clicks [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 1  # on the 'b'
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1  # cursor is at row 0..
+    4 <- 1  # ..and column 1
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-handles-mouse-clicks-outside-text [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  $clear-trace
+  assume-console [
+    left-click 1, 7  # last line, to the right of text
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1  # cursor row
+    4 <- 3  # cursor column
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-handles-mouse-clicks-outside-text-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  $clear-trace
+  assume-console [
+    left-click 1, 7  # interior line, to the right of text
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1  # cursor row
+    4 <- 3  # cursor column
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-handles-mouse-clicks-outside-text-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  $clear-trace
+  assume-console [
+    left-click 3, 7  # below text
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2  # cursor row
+    4 <- 3  # cursor column
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-handles-mouse-clicks-outside-column [
+  local-scope
+  assume-screen 10/width, 5/height
+  # editor occupies only left half of screen
+  e:&:editor <- new-editor [abc], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    # click on right half of screen
+    left-click 3, 8
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1  # no change to cursor row
+    4 <- 0  # ..or column
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-handles-mouse-clicks-in-menu-area [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    # click on first, 'menu' row
+    left-click 0, 3
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # no change to cursor
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+]
+
+scenario editor-inserts-characters-into-empty-editor [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    type [abc]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  check-trace-count-for-label 3, [print-character]
+]
+
+scenario editor-inserts-characters-at-cursor [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # type two letters at different places
+  assume-console [
+    type [0]
+    left-click 1, 2
+    type [d]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0adbc     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 7, [print-character]  # 4 for first letter, 3 for second
+]
+
+scenario editor-inserts-characters-at-cursor-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 5  # right of last line
+    type [d]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 1, [print-character]
+]
+
+scenario editor-inserts-characters-at-cursor-5 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 5  # right of non-last line
+    type [e]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abce      .
+    .d         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 1, [print-character]
+]
+
+scenario editor-inserts-characters-at-cursor-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 3, 5  # below all text
+    type [d]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 1, [print-character]
+]
+
+scenario editor-inserts-characters-at-cursor-4 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 3, 5  # below all text
+    type [e]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .de        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 1, [print-character]
+]
+
+scenario editor-inserts-characters-at-cursor-6 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 3, 5  # below all text
+    type [ef]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 2, [print-character]
+]
+
+scenario editor-moves-cursor-after-inserting-characters [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [ab], 0/left, 5/right
+  editor-render screen, e
+  assume-console [
+    type [01]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .01ab      .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]
+
+# if the cursor reaches the right margin, wrap the line
+
+scenario editor-wraps-line-on-insert [
+  local-scope
+  assume-screen 5/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 5/right
+  editor-render screen, e
+  # type a letter
+  assume-console [
+    type [e]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # no wrap yet
+  screen-should-contain [
+    .     .
+    .eabc .
+    .┈┈┈┈┈.
+    .     .
+    .     .
+  ]
+  # type a second letter
+  assume-console [
+    type [f]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # now wrap
+  screen-should-contain [
+    .     .
+    .efab↩.
+    .c    .
+    .┈┈┈┈┈.
+    .     .
+  ]
+]
+
+scenario editor-wraps-line-on-insert-2 [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  s:text <- new [abcdefg
+defg]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # type more text at the start
+  assume-console [
+    left-click 3, 0
+    type [abc]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor is not wrapped
+  memory-should-contain [
+    3 <- 3
+    4 <- 3
+  ]
+  # but line is wrapped
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .efg       .
+    .abcd↩     .
+    .efg       .
+  ]
+]
+
+after <insert-character-special-case> [
+  # if the line wraps at the cursor, move cursor to start of next row
+  {
+    # if either:
+    # a) we're at the end of the line and at the column of the wrap indicator, or
+    # b) we're not at end of line and just before the column of the wrap indicator
+    wrap-column:num <- copy right
+    before-wrap-column:num <- subtract wrap-column, 1
+    at-wrap?:bool <- greater-or-equal cursor-column, wrap-column
+    just-before-wrap?:bool <- greater-or-equal cursor-column, before-wrap-column
+    next:&:duplex-list:char <- next before-cursor
+    # at end of line? next == 0 || next.value == 10/newline
+    at-end-of-line?:bool <- equal next, null
+    {
+      break-if at-end-of-line?
+      next-character:char <- get *next, value:offset
+      at-end-of-line? <- equal next-character, 10/newline
+    }
+    # break unless ((eol? and at-wrap?) or (~eol? and just-before-wrap?))
+    move-cursor-to-next-line?:bool <- copy false
+    {
+      break-if at-end-of-line?
+      move-cursor-to-next-line? <- copy just-before-wrap?
+      # if we're moving the cursor because it's in the middle of a wrapping
+      # line, adjust it to left-most column
+      potential-new-cursor-column:num <- copy left
+    }
+    {
+      break-unless at-end-of-line?
+      move-cursor-to-next-line? <- copy at-wrap?
+      # if we're moving the cursor because it's at the end of a wrapping line,
+      # adjust it to one past the left-most column to make room for the
+      # newly-inserted wrap-indicator
+      potential-new-cursor-column:num <- add left, 1/make-room-for-wrap-indicator
+    }
+    break-unless move-cursor-to-next-line?
+    cursor-column <- copy potential-new-cursor-column
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    cursor-row <- add cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    # if we're out of the screen, scroll down
+    {
+      below-screen?:bool <- greater-or-equal cursor-row, screen-height
+      break-unless below-screen?
+      <scroll-down>
+    }
+    return true/go-render
+  }
+]
+
+scenario editor-wraps-cursor-after-inserting-characters-in-middle-of-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcde], 0/left, 5/right
+  assume-console [
+    left-click 1, 3  # right before the wrap icon
+    type [f]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abcf↩     .
+    .de        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 2  # cursor row
+    4 <- 0  # cursor column
+  ]
+]
+
+scenario editor-wraps-cursor-after-inserting-characters-at-end-of-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  # create an editor containing two lines
+  s:text <- new [abc
+xyz]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abc       .
+    .xyz       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    left-click 1, 4  # at end of first line
+    type [de]  # trigger wrap
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .e         .
+    .xyz       .
+    .┈┈┈┈┈     .
+  ]
+]
+
+scenario editor-wraps-cursor-to-left-margin [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcde], 2/left, 7/right
+  assume-console [
+    left-click 1, 5  # line is full; no wrap icon yet
+    type [01]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .  abc0↩   .
+    .  1de     .
+    .  ┈┈┈┈┈   .
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 2  # cursor row
+    4 <- 3  # cursor column
+  ]
+]
+
+# if newline, move cursor to start of next line, and maybe align indent with previous line
+
+container editor [
+  indent?:bool
+]
+
+after <editor-initialization> [
+  *result <- put *result, indent?:offset, true
+]
+
+scenario editor-moves-cursor-down-after-inserting-newline [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  assume-console [
+    type [0
+1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0         .
+    .1abc      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <handle-special-character> [
+  {
+    newline?:bool <- equal c, 10/newline
+    break-unless newline?
+    <begin-insert-enter>
+    insert-new-line-and-indent editor, screen
+    <end-insert-enter>
+    return true/go-render
+  }
+]
+
+def insert-new-line-and-indent editor:&:editor, screen:&:screen -> editor:&:editor, screen:&:screen [
+  local-scope
+  load-inputs
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  screen-height:num <- screen-height screen
+  # update cursor coordinates
+  at-start-of-wrapped-line?:bool <- at-start-of-wrapped-line? editor
+  {
+    break-if at-start-of-wrapped-line?
+    cursor-row <- add cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+  }
+  cursor-column <- copy left
+  *editor <- put *editor, cursor-column:offset, cursor-column
+  # maybe scroll
+  {
+    below-screen?:bool <- greater-or-equal cursor-row, screen-height  # must be equal, never greater
+    break-unless below-screen?
+    <scroll-down2>
+    cursor-row <- subtract cursor-row, 1  # bring back into screen range
+    *editor <- put *editor, cursor-row:offset, cursor-row
+  }
+  # insert newline
+  insert 10/newline, before-cursor
+  before-cursor <- next before-cursor
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  # indent if necessary
+  indent?:bool <- get *editor, indent?:offset
+  return-unless indent?
+  d:&:duplex-list:char <- get *editor, data:offset
+  end-of-previous-line:&:duplex-list:char <- prev before-cursor
+  indent:num <- line-indent end-of-previous-line, d
+  i:num <- copy 0
+  {
+    indent-done?:bool <- greater-or-equal i, indent
+    break-if indent-done?
+    insert-at-cursor editor, 32/space, screen
+    i <- add i, 1
+    loop
+  }
+]
+
+def at-start-of-wrapped-line? editor:&:editor -> result:bool [
+  local-scope
+  load-inputs
+  left:num <- get *editor, left:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  cursor-at-left?:bool <- equal cursor-column, left
+  return-unless cursor-at-left?, false
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  before-before-cursor:&:duplex-list:char <- prev before-cursor
+  return-unless before-before-cursor, false  # cursor is at start of editor
+  char-before-cursor:char <- get *before-cursor, value:offset
+  cursor-after-newline?:bool <- equal char-before-cursor, 10/newline
+  return-if cursor-after-newline?, false
+  # if cursor is at left margin and not at start, but previous character is not a newline,
+  # then we're at start of a wrapped line
+  return true
+]
+
+# takes a pointer 'curr' into the doubly-linked list and its sentinel, counts
+# the number of spaces at the start of the line containing 'curr'.
+def line-indent curr:&:duplex-list:char, start:&:duplex-list:char -> result:num [
+  local-scope
+  load-inputs
+  result:num <- copy 0
+  return-unless curr
+  at-start?:bool <- equal curr, start
+  return-if at-start?
+  {
+    curr <- prev curr
+    break-unless curr
+    at-start?:bool <- equal curr, start
+    break-if at-start?
+    c:char <- get *curr, value:offset
+    at-newline?:bool <- equal c, 10/newline
+    break-if at-newline?
+    # if c is a space, increment result
+    is-space?:bool <- equal c, 32/space
+    {
+      break-unless is-space?
+      result <- add result, 1
+    }
+    # if c is not a space, reset result
+    {
+      break-if is-space?
+      result <- copy 0
+    }
+    loop
+  }
+]
+
+scenario editor-moves-cursor-down-after-inserting-newline-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 1/left, 10/right
+  assume-console [
+    type [0
+1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    . 0        .
+    . 1abc     .
+    . ┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-clears-previous-line-completely-after-inserting-newline [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcde], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    press enter
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # line should be fully cleared
+  screen-should-contain [
+    .          .
+    .          .
+    .abcd↩     .
+    .e         .
+    .┈┈┈┈┈     .
+  ]
+]
+
+scenario editor-splits-wrapped-line-after-inserting-newline [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcdef], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    left-click 2, 0
+    press enter
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .ef        .
+    .┈┈┈┈┈     .
+  ]
+  memory-should-contain [
+    10 <- 2  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+]
+
+scenario editor-inserts-indent-after-newline [
+  local-scope
+  assume-screen 10/width, 10/height
+  s:text <- new [ab
+  cd
+ef]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  # position cursor after 'cd' and hit 'newline'
+  assume-console [
+    left-click 2, 8
+    type [
+]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor should be below start of previous line
+  memory-should-contain [
+    3 <- 3  # cursor row
+    4 <- 2  # cursor column (indented)
+  ]
+]
+
+scenario editor-skips-indent-around-paste [
+  local-scope
+  assume-screen 10/width, 10/height
+  s:text <- new [ab
+  cd
+ef]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  # position cursor after 'cd' and hit 'newline' surrounded by paste markers
+  assume-console [
+    left-click 2, 8
+    press 65507  # start paste
+    press enter
+    press 65506  # end paste
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor should be below start of previous line
+  memory-should-contain [
+    3 <- 3  # cursor row
+    4 <- 0  # cursor column (not indented)
+  ]
+]
+
+after <handle-special-key> [
+  {
+    paste-start?:bool <- equal k, 65507/paste-start
+    break-unless paste-start?
+    *editor <- put *editor, indent?:offset, false
+    return true/go-render
+  }
+]
+
+after <handle-special-key> [
+  {
+    paste-end?:bool <- equal k, 65506/paste-end
+    break-unless paste-end?
+    *editor <- put *editor, indent?:offset, true
+    return true/go-render
+  }
+]
+
+## helpers
+
+def draw-horizontal screen:&:screen, row:num, x:num, right:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  height:num <- screen-height screen
+  past-bottom?:bool <- greater-or-equal row, height
+  return-if past-bottom?
+  style:char, style-found?:bool <- next-input
+  {
+    break-if style-found?
+    style <- copy 9472/horizontal
+  }
+  color:num, color-found?:bool <- next-input
+  {
+    # default color to white
+    break-if color-found?
+    color <- copy 245/grey
+  }
+  bg-color:num, bg-color-found?:bool <- next-input
+  {
+    break-if bg-color-found?
+    bg-color <- copy 0/black
+  }
+  screen <- move-cursor screen, row, x
+  {
+    continue?:bool <- lesser-or-equal x, right  # right is inclusive, to match editor semantics
+    break-unless continue?
+    print screen, style, color, bg-color
+    x <- add x, 1
+    loop
+  }
+]
diff --git a/archive/2.vm/edit/003-shortcuts.mu b/archive/2.vm/edit/003-shortcuts.mu
new file mode 100644
index 00000000..872dfcea
--- /dev/null
+++ b/archive/2.vm/edit/003-shortcuts.mu
@@ -0,0 +1,4462 @@
+## special shortcuts for manipulating the editor
+# Some keys on the keyboard generate unicode characters, others generate
+# terminfo key codes. We need to modify different places in the two cases.
+
+# tab - insert two spaces
+
+scenario editor-inserts-two-spaces-on-tab [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [ab
+cd]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press tab
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .  ab      .
+    .cd        .
+  ]
+  # we render at most two editor rows worth (one row for each space)
+  check-trace-count-for-label-lesser-than 10, [print-character]
+]
+
+scenario editor-inserts-two-spaces-and-wraps-line-on-tab [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcd], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press tab
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .  ab↩     .
+    .cd        .
+  ]
+  # we re-render the whole editor
+  check-trace-count-for-label-greater-than 10, [print-character]
+]
+
+after <handle-special-character> [
+  {
+    tab?:bool <- equal c, 9/tab
+    break-unless tab?
+    <begin-insert-character>
+    # todo: decompose insert-at-cursor into editor update and screen update,
+    # so that 'tab' doesn't render the current line multiple times
+    insert-at-cursor editor, 32/space, screen
+    go-render? <- insert-at-cursor editor, 32/space, screen
+    <end-insert-character>
+    return
+  }
+]
+
+# backspace - delete character before cursor
+
+scenario editor-handles-backspace-key [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 1
+    press backspace
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .bc        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 0
+  ]
+  check-trace-count-for-label 3, [print-character]  # length of original line to overwrite
+]
+
+after <handle-special-character> [
+  {
+    delete-previous-character?:bool <- equal c, 8/backspace
+    break-unless delete-previous-character?
+    <begin-backspace-character>
+    go-render?:bool, backspaced-cell:&:duplex-list:char <- delete-before-cursor editor, screen
+    <end-backspace-character>
+    return
+  }
+]
+
+# return values:
+#   go-render? - whether caller needs to update the screen
+#   backspaced-cell - value deleted (or 0 if nothing was deleted) so we can save it for undo, etc.
+def delete-before-cursor editor:&:editor, screen:&:screen -> go-render?:bool, backspaced-cell:&:duplex-list:char, editor:&:editor, screen:&:screen [
+  local-scope
+  load-inputs
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  data:&:duplex-list:char <- get *editor, data:offset
+  # if at start of text (before-cursor at § sentinel), return
+  prev:&:duplex-list:char <- prev before-cursor
+  return-unless prev, false/no-more-render, null/nothing-deleted
+  trace 10, [app], [delete-before-cursor]
+  original-row:num <- get *editor, cursor-row:offset
+  scroll?:bool <- move-cursor-coordinates-left editor
+  backspaced-cell:&:duplex-list:char <- copy before-cursor
+  data <- remove before-cursor, data  # will also neatly trim next/prev pointers in backspaced-cell/before-cursor
+  before-cursor <- copy prev
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  return-if scroll?, true/go-render
+  screen-width:num <- screen-width screen
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  # did we just backspace over a newline?
+  same-row?:bool <- equal cursor-row, original-row
+  return-unless same-row?, true/go-render
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  curr:&:duplex-list:char <- next before-cursor
+  screen <- move-cursor screen, cursor-row, cursor-column
+  curr-column:num <- copy cursor-column
+  {
+    # hit right margin? give up and let caller render
+    at-right?:bool <- greater-or-equal curr-column, right
+    return-if at-right?, true/go-render
+    break-unless curr
+    # newline? done.
+    currc:char <- get *curr, value:offset
+    at-newline?:bool <- equal currc, 10/newline
+    break-if at-newline?
+    screen <- print screen, currc
+    curr-column <- add curr-column, 1
+    curr <- next curr
+    loop
+  }
+  # we're guaranteed not to be at the right margin
+  space:char <- copy 32/space
+  screen <- print screen, space
+  go-render? <- copy false
+]
+
+def move-cursor-coordinates-left editor:&:editor -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  go-render?:bool <- copy false
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  left:num <- get *editor, left:offset
+  # if not at left margin, move one character left
+  {
+    at-left-margin?:bool <- equal cursor-column, left
+    break-if at-left-margin?
+    trace 10, [app], [decrementing cursor column]
+    cursor-column <- subtract cursor-column, 1
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    return
+  }
+  # if at left margin, we must move to previous row:
+  top-of-screen?:bool <- equal cursor-row, 1  # exclude menu bar
+  {
+    break-if top-of-screen?
+    cursor-row <- subtract cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+  }
+  {
+    break-unless top-of-screen?
+    <scroll-up>
+    go-render? <- copy true
+  }
+  {
+    # case 1: if previous character was newline, figure out how long the previous line is
+    previous-character:char <- get *before-cursor, value:offset
+    previous-character-is-newline?:bool <- equal previous-character, 10/newline
+    break-unless previous-character-is-newline?
+    # compute length of previous line
+    trace 10, [app], [switching to previous line]
+    d:&:duplex-list:char <- get *editor, data:offset
+    end-of-line:num <- previous-line-length before-cursor, d
+    right:num <- get *editor, right:offset
+    width:num <- subtract right, left
+    wrap?:bool <- greater-than end-of-line, width
+    {
+      break-unless wrap?
+      _, column-offset:num <- divide-with-remainder end-of-line, width
+      cursor-column <- add left, column-offset
+      *editor <- put *editor, cursor-column:offset, cursor-column
+    }
+    {
+      break-if wrap?
+      cursor-column <- add left, end-of-line
+      *editor <- put *editor, cursor-column:offset, cursor-column
+    }
+    return
+  }
+  # case 2: if previous-character was not newline, we're just at a wrapped line
+  trace 10, [app], [wrapping to previous line]
+  right:num <- get *editor, right:offset
+  cursor-column <- subtract right, 1  # leave room for wrap icon
+  *editor <- put *editor, cursor-column:offset, cursor-column
+]
+
+# takes a pointer 'curr' into the doubly-linked list and its sentinel, counts
+# the length of the previous line before the 'curr' pointer.
+def previous-line-length curr:&:duplex-list:char, start:&:duplex-list:char -> result:num [
+  local-scope
+  load-inputs
+  result:num <- copy 0
+  return-unless curr
+  at-start?:bool <- equal curr, start
+  return-if at-start?
+  {
+    curr <- prev curr
+    break-unless curr
+    at-start?:bool <- equal curr, start
+    break-if at-start?
+    c:char <- get *curr, value:offset
+    at-newline?:bool <- equal c, 10/newline
+    break-if at-newline?
+    result <- add result, 1
+    loop
+  }
+]
+
+scenario editor-clears-last-line-on-backspace [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [ab
+cd]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  assume-console [
+    left-click 2, 0
+    press backspace
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 2
+  ]
+]
+
+scenario editor-joins-and-wraps-lines-on-backspace [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with two long-ish but non-wrapping lines
+  s:text <- new [abc def
+ghi jkl]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # position the cursor at the start of the second and hit backspace
+  assume-console [
+    left-click 2, 0
+    press backspace
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # resulting single line should wrap correctly
+  screen-should-contain [
+    .          .
+    .abc defgh↩.
+    .i jkl     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-wraps-long-lines-on-backspace [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor in part of the screen with a long line
+  e:&:editor <- new-editor [abc def ghij], 0/left, 8/right
+  editor-render screen, e
+  # confirm that it wraps
+  screen-should-contain [
+    .          .
+    .abc def↩  .
+    . ghij     .
+    .┈┈┈┈┈┈┈┈  .
+  ]
+  $clear-trace
+  # position the cursor somewhere in the middle of the top screen line and hit backspace
+  assume-console [
+    left-click 1, 4
+    press backspace
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # resulting single line should wrap correctly and not overflow its bounds
+  screen-should-contain [
+    .          .
+    .abcdef ↩  .
+    .ghij      .
+    .┈┈┈┈┈┈┈┈  .
+    .          .
+  ]
+]
+
+# delete - delete character at cursor
+
+scenario editor-handles-delete-key [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press delete
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .bc        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 3, [print-character]  # length of original line to overwrite
+  $clear-trace
+  assume-console [
+    press delete
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .c         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 2, [print-character]  # new length to overwrite
+]
+
+after <handle-special-key> [
+  {
+    delete-next-character?:bool <- equal k, 65522/delete
+    break-unless delete-next-character?
+    <begin-delete-character>
+    go-render?:bool, deleted-cell:&:duplex-list:char <- delete-at-cursor editor, screen
+    <end-delete-character>
+    return
+  }
+]
+
+def delete-at-cursor editor:&:editor, screen:&:screen -> go-render?:bool, deleted-cell:&:duplex-list:char, editor:&:editor, screen:&:screen [
+  local-scope
+  load-inputs
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  data:&:duplex-list:char <- get *editor, data:offset
+  deleted-cell:&:duplex-list:char <- next before-cursor
+  return-unless deleted-cell, false/don't-render
+  currc:char <- get *deleted-cell, value:offset
+  data <- remove deleted-cell, data
+  deleted-newline?:bool <- equal currc, 10/newline
+  return-if deleted-newline?, true/go-render
+  # wasn't a newline? render rest of line
+  curr:&:duplex-list:char <- next before-cursor  # refresh after remove above
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  screen <- move-cursor screen, cursor-row, cursor-column
+  curr-column:num <- copy cursor-column
+  screen-width:num <- screen-width screen
+  {
+    # hit right margin? give up and let caller render
+    at-right?:bool <- greater-or-equal curr-column, screen-width
+    return-if at-right?, true/go-render
+    break-unless curr
+    currc:char <- get *curr, value:offset
+    at-newline?:bool <- equal currc, 10/newline
+    break-if at-newline?
+    screen <- print screen, currc
+    curr-column <- add curr-column, 1
+    curr <- next curr
+    loop
+  }
+  # we're guaranteed not to be at the right margin
+  space:char <- copy 32/space
+  screen <- print screen, space
+  go-render? <- copy false
+]
+
+# right arrow
+
+scenario editor-moves-cursor-right-with-key [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press right-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .a0bc      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 3, [print-character]  # 0 and following characters
+]
+
+after <handle-special-key> [
+  {
+    move-to-next-character?:bool <- equal k, 65514/right-arrow
+    break-unless move-to-next-character?
+    # if not at end of text
+    next-cursor:&:duplex-list:char <- next before-cursor
+    break-unless next-cursor
+    # scan to next character
+    <begin-move-cursor>
+    before-cursor <- copy next-cursor
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    go-render?:bool <- move-cursor-coordinates-right editor, screen-height
+    screen <- move-cursor screen, cursor-row, cursor-column
+    undo-coalesce-tag:num <- copy 2/right-arrow
+    <end-move-cursor>
+    return
+  }
+]
+
+def move-cursor-coordinates-right editor:&:editor, screen-height:num -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  before-cursor:&:duplex-list:char <- get *editor before-cursor:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  # if crossed a newline, move cursor to start of next row
+  {
+    old-cursor-character:char <- get *before-cursor, value:offset
+    was-at-newline?:bool <- equal old-cursor-character, 10/newline
+    break-unless was-at-newline?
+    cursor-row <- add cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- copy left
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    below-screen?:bool <- greater-or-equal cursor-row, screen-height  # must be equal
+    return-unless below-screen?, false/don't-render
+    <scroll-down>
+    cursor-row <- subtract cursor-row, 1  # bring back into screen range
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    return true/go-render
+  }
+  # if the line wraps, move cursor to start of next row
+  {
+    # if we're at the column just before the wrap indicator
+    wrap-column:num <- subtract right, 1
+    at-wrap?:bool <- equal cursor-column, wrap-column
+    break-unless at-wrap?
+    # and if next character isn't newline
+    next:&:duplex-list:char <- next before-cursor
+    break-unless next
+    next-character:char <- get *next, value:offset
+    newline?:bool <- equal next-character, 10/newline
+    break-if newline?
+    cursor-row <- add cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- copy left
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    below-screen?:bool <- greater-or-equal cursor-row, screen-height  # must be equal
+    return-unless below-screen?, false/no-more-render
+    <scroll-down>
+    cursor-row <- subtract cursor-row, 1  # bring back into screen range
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    return true/go-render
+  }
+  # otherwise move cursor one character right
+  cursor-column <- add cursor-column, 1
+  *editor <- put *editor, cursor-column:offset, cursor-column
+  go-render? <- copy false
+]
+
+scenario editor-moves-cursor-to-next-line-with-right-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # type right-arrow a few times to get to start of second line
+  assume-console [
+    press right-arrow
+    press right-arrow
+    press right-arrow
+    press right-arrow  # next line
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  check-trace-count-for-label 0, [print-character]
+  # type something and ensure it goes where it should
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .0d        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 2, [print-character]  # new length of second line
+]
+
+scenario editor-moves-cursor-to-next-line-with-right-arrow-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 1/left, 10/right
+  editor-render screen, e
+  assume-console [
+    press right-arrow
+    press right-arrow
+    press right-arrow
+    press right-arrow  # next line
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    . abc      .
+    . 0d       .
+    . ┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-moves-cursor-to-next-wrapped-line-with-right-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcdef], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 3
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-cursor-to-next-wrapped-line-with-right-arrow-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  # line just barely wrapping
+  e:&:editor <- new-editor [abcde], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  # position cursor at last character before wrap and hit right-arrow
+  assume-console [
+    left-click 1, 3
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+  # now hit right arrow again
+  assume-console [
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-cursor-to-next-wrapped-line-with-right-arrow-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcdef], 1/left, 6/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 4
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    . abcd↩    .
+    . ef       .
+    . ┈┈┈┈┈    .
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-cursor-to-next-line-with-right-arrow-at-end-of-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # move to end of line, press right-arrow, type a character
+  assume-console [
+    left-click 1, 3
+    press right-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # new character should be in next line
+  screen-should-contain [
+    .          .
+    .abc       .
+    .0d        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 2, [print-character]
+]
+
+# todo: ctrl-right: next word-end
+
+# left arrow
+
+scenario editor-moves-cursor-left-with-key [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 2
+    press left-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .a0bc      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 3, [print-character]
+]
+
+after <handle-special-key> [
+  {
+    move-to-previous-character?:bool <- equal k, 65515/left-arrow
+    break-unless move-to-previous-character?
+    trace 10, [app], [left arrow]
+    # if not at start of text (before-cursor at § sentinel)
+    prev:&:duplex-list:char <- prev before-cursor
+    return-unless prev, false/don't-render
+    <begin-move-cursor>
+    go-render? <- move-cursor-coordinates-left editor
+    before-cursor <- copy prev
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    undo-coalesce-tag:num <- copy 1/left-arrow
+    <end-move-cursor>
+    return
+  }
+]
+
+scenario editor-moves-cursor-to-previous-line-with-left-arrow-at-start-of-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with two lines
+  s:text <- new [abc
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # position cursor at start of second line (so there's no previous newline)
+  assume-console [
+    left-click 2, 0
+    press left-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-cursor-to-previous-line-with-left-arrow-at-start-of-line-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with three lines
+  s:text <- new [abc
+def
+g]
+  e:&:editor <- new-editor s:text, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # position cursor further down (so there's a newline before the character at
+  # the cursor)
+  assume-console [
+    left-click 3, 0
+    press left-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def0      .
+    .g         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  check-trace-count-for-label 1, [print-character]  # just the '0'
+]
+
+scenario editor-moves-cursor-to-previous-line-with-left-arrow-at-start-of-line-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+def
+g]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # position cursor at start of text, press left-arrow, then type a character
+  assume-console [
+    left-click 1, 0
+    press left-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # left-arrow should have had no effect
+  screen-should-contain [
+    .          .
+    .0abc      .
+    .def       .
+    .g         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  check-trace-count-for-label 4, [print-character]  # length of first line
+]
+
+scenario editor-moves-cursor-to-previous-line-with-left-arrow-at-start-of-line-4 [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with text containing an empty line
+  s:text <- new [abc
+
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e:&:editor
+  $clear-trace
+  # position cursor right after empty line
+  assume-console [
+    left-click 3, 0
+    press left-arrow
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .0         .
+    .d         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  check-trace-count-for-label 1, [print-character]  # just the '0'
+]
+
+scenario editor-moves-across-screen-lines-across-wrap-with-left-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with a wrapping line
+  e:&:editor <- new-editor [abcdef], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # position cursor right after empty line
+  assume-console [
+    left-click 2, 0
+    press left-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1  # previous row
+    4 <- 3  # right margin except wrap icon
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-across-screen-lines-to-wrapping-line-with-left-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with a wrapping line followed by a second line
+  s:text <- new [abcdef
+g]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .g         .
+    .┈┈┈┈┈     .
+  ]
+  # position cursor right after empty line
+  assume-console [
+    left-click 3, 0
+    press left-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2  # previous row
+    4 <- 2  # end of wrapped line
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-across-screen-lines-to-non-wrapping-line-with-left-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  # initialize editor with a line on the verge of wrapping, followed by a second line
+  s:text <- new [abcd
+e]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # position cursor right after empty line
+  assume-console [
+    left-click 2, 0
+    press left-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1  # previous row
+    4 <- 4  # end of wrapped line
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+# todo: ctrl-left: previous word-start
+
+# up arrow
+
+scenario editor-moves-to-previous-line-with-up-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 2, 1
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .a0bc      .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <handle-special-key> [
+  {
+    move-to-previous-line?:bool <- equal k, 65517/up-arrow
+    break-unless move-to-previous-line?
+    <begin-move-cursor>
+    go-render? <- move-to-previous-line editor
+    undo-coalesce-tag:num <- copy 3/up-arrow
+    <end-move-cursor>
+    return
+  }
+]
+
+def move-to-previous-line editor:&:editor -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  go-render?:bool <- copy false
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  already-at-top?:bool <- lesser-or-equal cursor-row, 1/top
+  {
+    # if cursor not at top, move it
+    break-if already-at-top?
+    # if not at start of screen line, move to start of screen line (previous newline)
+    # then scan back another line
+    # if either step fails, give up without modifying cursor or coordinates
+    curr:&:duplex-list:char <- copy before-cursor
+    old:&:duplex-list:char <- copy curr
+    {
+      at-left?:bool <- equal cursor-column, left
+      break-if at-left?
+      curr <- before-previous-screen-line curr, editor
+      no-motion?:bool <- equal curr, old
+      return-if no-motion?
+    }
+    {
+      curr <- before-previous-screen-line curr, editor
+      no-motion?:bool <- equal curr, old
+      return-if no-motion?
+    }
+    before-cursor <- copy curr
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    cursor-row <- subtract cursor-row, 1
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    # scan ahead to right column or until end of line
+    target-column:num <- copy cursor-column
+    cursor-column <- copy left
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    {
+      done?:bool <- greater-or-equal cursor-column, target-column
+      break-if done?
+      curr:&:duplex-list:char <- next before-cursor
+      break-unless curr
+      currc:char <- get *curr, value:offset
+      at-newline?:bool <- equal currc, 10/newline
+      break-if at-newline?
+      #
+      before-cursor <- copy curr
+      *editor <- put *editor, before-cursor:offset, before-cursor
+      cursor-column <- add cursor-column, 1
+      *editor <- put *editor, cursor-column:offset, cursor-column
+      loop
+    }
+    return
+  }
+  {
+    # if cursor already at top, scroll up
+    break-unless already-at-top?
+    <scroll-up>
+    return true/go-render
+  }
+]
+
+# Takes a pointer into the doubly-linked list, scans back to before start of
+# previous *wrapped* line.
+# Returns original if no next newline.
+# Beware: never return null pointer.
+def before-previous-screen-line in:&:duplex-list:char, editor:&:editor -> out:&:duplex-list:char [
+  local-scope
+  load-inputs
+  curr:&:duplex-list:char <- copy in
+  c:char <- get *curr, value:offset
+  # compute max, number of characters to skip
+  #   1 + len%(width-1)
+  #   except rotate second term to vary from 1 to width-1 rather than 0 to width-2
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  max-line-length:num <- subtract right, left, -1/exclusive-right, 1/wrap-icon
+  sentinel:&:duplex-list:char <- get *editor, data:offset
+  len:num <- previous-line-length curr, sentinel
+  {
+    break-if len
+    # empty line; just skip this newline
+    prev:&:duplex-list:char <- prev curr
+    return-unless prev, curr
+    return prev
+  }
+  _, max:num <- divide-with-remainder len, max-line-length
+  # remainder 0 => scan one width-worth
+  {
+    break-if max
+    max <- copy max-line-length
+  }
+  max <- add max, 1
+  count:num <- copy 0
+  # skip 'max' characters
+  {
+    done?:bool <- greater-or-equal count, max
+    break-if done?
+    prev:&:duplex-list:char <- prev curr
+    break-unless prev
+    curr <- copy prev
+    count <- add count, 1
+    loop
+  }
+  return curr
+]
+
+scenario editor-adjusts-column-at-previous-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [ab
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 2, 3
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .ab0       .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-adjusts-column-at-empty-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 2, 3
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0         .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-moves-to-previous-line-from-zero-margin [
+  local-scope
+  assume-screen 10/width, 5/height
+  # start out with three lines
+  s:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # click on the third line and hit up-arrow, so you end up just after a newline
+  assume-console [
+    left-click 3, 0
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .0def      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-moves-to-previous-line-from-left-margin [
+  local-scope
+  assume-screen 10/width, 5/height
+  # start out with three lines
+  s:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor s, 1/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # click on the third line and hit up-arrow, so you end up just after a newline
+  assume-console [
+    left-click 3, 1
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    . abc      .
+    . 0def     .
+    . ghi      .
+    . ┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-moves-to-top-line-in-presence-of-wrapped-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcde], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .e         .
+    .┈┈┈┈┈     .
+  ]
+  $clear-trace
+  assume-console [
+    left-click 2, 0
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0abc↩     .
+    .de        .
+    .┈┈┈┈┈     .
+  ]
+]
+
+scenario editor-moves-to-top-line-in-presence-of-wrapped-line-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+defgh]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abc       .
+    .defg↩     .
+    .h         .
+    .┈┈┈┈┈     .
+  ]
+  $clear-trace
+  assume-console [
+    left-click 3, 0
+    press up-arrow
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0abc      .
+    .defg↩     .
+    .h         .
+    .┈┈┈┈┈     .
+  ]
+]
+
+# down arrow
+
+scenario editor-moves-to-next-line-with-down-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+def]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # cursor starts out at (1, 0)
+  assume-console [
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # ..and ends at (2, 0)
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .0def      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <handle-special-key> [
+  {
+    move-to-next-line?:bool <- equal k, 65516/down-arrow
+    break-unless move-to-next-line?
+    <begin-move-cursor>
+    go-render? <- move-to-next-line editor, screen-height
+    undo-coalesce-tag:num <- copy 4/down-arrow
+    <end-move-cursor>
+    return
+  }
+]
+
+def move-to-next-line editor:&:editor, screen-height:num -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  last-line:num <- subtract screen-height, 1
+  bottom:num <- get *editor, bottom:offset
+  at-bottom-of-screen?:bool <- greater-or-equal bottom, last-line
+  {
+    break-if before-cursor
+    {
+      break-if at-bottom-of-screen?
+      return false/don't-render
+    }
+    {
+      break-unless at-bottom-of-screen?
+      jump +try-to-scroll
+    }
+  }
+  next:&:duplex-list:char <- next before-cursor
+  {
+    break-if next
+    {
+      break-if at-bottom-of-screen?
+      return false/don't-render
+    }
+    {
+      break-unless at-bottom-of-screen?
+      jump +try-to-scroll
+    }
+  }
+  already-at-bottom?:bool <- greater-or-equal cursor-row, last-line
+  {
+    # if cursor not at bottom, move it
+    break-if already-at-bottom?
+    target-column:num <- copy cursor-column
+    # scan to start of next line
+    {
+      next:&:duplex-list:char <- next before-cursor
+      break-unless next
+      done?:bool <- greater-or-equal cursor-column, right
+      break-if done?
+      cursor-column <- add cursor-column, 1
+      before-cursor <- copy next
+      c:char <- get *next, value:offset
+      at-newline?:bool <- equal c, 10/newline
+      break-if at-newline?
+      loop
+    }
+    {
+      break-if next
+      {
+        break-if at-bottom-of-screen?
+        return false/don't-render
+      }
+      {
+        break-unless at-bottom-of-screen?
+        jump +try-to-scroll
+      }
+    }
+    cursor-row <- add cursor-row, 1
+    cursor-column <- copy left
+    {
+      next:&:duplex-list:char <- next before-cursor
+      break-unless next
+      c:char <- get *next, value:offset
+      at-newline?:bool <- equal c, 10/newline
+      break-if at-newline?
+      done?:bool <- greater-or-equal cursor-column, target-column
+      break-if done?
+      cursor-column <- add cursor-column, 1
+      before-cursor <- copy next
+      loop
+    }
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    return false/don't-render
+  }
+  +try-to-scroll
+  <scroll-down>
+  go-render? <- copy true
+]
+
+scenario editor-adjusts-column-at-next-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  # second line is shorter than first
+  s:text <- new [abcde
+fg
+hi]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # move to end of first line, then press down
+  assume-console [
+    left-click 1, 8
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor doesn't go vertically down, it goes to end of shorter line
+  memory-should-contain [
+    3 <- 2
+    4 <- 2
+  ]
+  check-trace-count-for-label 0, [print-character]
+  assume-console [
+    type [0]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abcde     .
+    .fg0       .
+    .hi        .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-moves-down-within-wrapped-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abcdefghijklmno], 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcdefghi↩.
+    .jklmno    .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # position cursor on first screen line, but past end of second screen line
+  assume-console [
+    left-click 1, 8
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor should be at end of second screen line
+  memory-should-contain [
+    3 <- 2
+    4 <- 6
+  ]
+]
+
+# ctrl-a/home - move cursor to start of line
+
+scenario editor-moves-to-start-of-line-with-ctrl-a [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on second line, press ctrl-a
+  assume-console [
+    left-click 2, 3
+    press ctrl-a
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to start of line
+  memory-should-contain [
+    4 <- 2
+    5 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+after <handle-special-character> [
+  {
+    move-to-start-of-line?:bool <- equal c, 1/ctrl-a
+    break-unless move-to-start-of-line?
+    <begin-move-cursor>
+    move-to-start-of-screen-line editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    return false/don't-render
+  }
+]
+
+after <handle-special-key> [
+  {
+    move-to-start-of-line?:bool <- equal k, 65521/home
+    break-unless move-to-start-of-line?
+    <begin-move-cursor>
+    move-to-start-of-screen-line editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    return false/don't-render
+  }
+]
+
+# handles wrapped lines
+# precondition: cursor-column should be in a consistent state
+def move-to-start-of-screen-line editor:&:editor -> editor:&:editor [
+  local-scope
+  load-inputs
+  # update cursor column
+  left:num <- get *editor, left:offset
+  col:num <- get *editor, cursor-column:offset
+  # update before-cursor
+  curr:&:duplex-list:char <- get *editor, before-cursor:offset
+  # while not at start of line, move
+  {
+    done?:bool <- equal col, left
+    break-if done?
+    assert curr, [move-to-start-of-line tried to move before start of text]
+    curr <- prev curr
+    col <- subtract col, 1
+    loop
+  }
+  *editor <- put *editor, cursor-column:offset, col
+  *editor <- put *editor, before-cursor:offset, curr
+]
+
+scenario editor-moves-to-start-of-line-with-ctrl-a-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line (no newline before), press ctrl-a
+  assume-console [
+    left-click 1, 3
+    press ctrl-a
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to start of line
+  memory-should-contain [
+    4 <- 1
+    5 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-start-of-line-with-home [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  $clear-trace
+  # start on second line, press 'home'
+  assume-console [
+    left-click 2, 3
+    press home
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to start of line
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-start-of-line-with-home-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line (no newline before), press 'home'
+  assume-console [
+    left-click 1, 3
+    press home
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to start of line
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-start-of-screen-line-with-ctrl-a [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [123456], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .1234↩     .
+    .56        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  $clear-trace
+  # start on second line, press ctrl-a then up
+  assume-console [
+    left-click 2, 1
+    press ctrl-a
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to start of first line
+  memory-should-contain [
+    4 <- 1  # cursor-row
+    5 <- 0  # cursor-column
+  ]
+  check-trace-count-for-label 0, [print-character]
+  # make sure before-cursor is in sync
+  assume-console [
+    type [a]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .a123↩     .
+    .456       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1  # cursor-row
+    5 <- 1  # cursor-column
+  ]
+]
+
+# ctrl-e/end - move cursor to end of line
+
+scenario editor-moves-to-end-of-line-with-ctrl-e [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line, press ctrl-e
+  assume-console [
+    left-click 1, 1
+    press ctrl-e
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to end of line
+  memory-should-contain [
+    4 <- 1
+    5 <- 3
+  ]
+  check-trace-count-for-label 0, [print-character]
+  # editor inserts future characters at cursor
+  assume-console [
+    type [z]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 4
+  ]
+  screen-should-contain [
+    .          .
+    .123z      .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 1, [print-character]
+]
+
+after <handle-special-character> [
+  {
+    move-to-end-of-line?:bool <- equal c, 5/ctrl-e
+    break-unless move-to-end-of-line?
+    <begin-move-cursor>
+    move-to-end-of-line editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    return false/don't-render
+  }
+]
+
+after <handle-special-key> [
+  {
+    move-to-end-of-line?:bool <- equal k, 65520/end
+    break-unless move-to-end-of-line?
+    <begin-move-cursor>
+    move-to-end-of-line editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    return false/don't-render
+  }
+]
+
+def move-to-end-of-line editor:&:editor -> editor:&:editor [
+  local-scope
+  load-inputs
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  right:num <- get *editor, right:offset
+  # while not at end of line, move
+  {
+    next:&:duplex-list:char <- next before-cursor
+    break-unless next  # end of text
+    nextc:char <- get *next, value:offset
+    at-end-of-line?:bool <- equal nextc, 10/newline
+    break-if at-end-of-line?
+    cursor-column <- add cursor-column, 1
+    at-right?:bool <- equal cursor-column, right
+    break-if at-right?
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    before-cursor <- copy next
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    loop
+  }
+]
+
+scenario editor-moves-to-end-of-line-with-ctrl-e-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on second line (no newline after), press ctrl-e
+  assume-console [
+    left-click 2, 1
+    press ctrl-e
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to end of line
+  memory-should-contain [
+    4 <- 2
+    5 <- 3
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-end-of-line-with-end [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line, press 'end'
+  assume-console [
+    left-click 1, 1
+    press end
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to end of line
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-end-of-line-with-end-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on second line (no newline after), press 'end'
+  assume-console [
+    left-click 2, 1
+    press end
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to end of line
+  memory-should-contain [
+    3 <- 2
+    4 <- 3
+  ]
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-moves-to-end-of-wrapped-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123456
+789]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line, press 'end'
+  assume-console [
+    left-click 1, 1
+    press end
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves to end of line
+  memory-should-contain [
+    10 <- 1
+    11 <- 3
+  ]
+  # no prints
+  check-trace-count-for-label 0, [print-character]
+  # before-cursor is also consistent
+  assume-console [
+    type [a]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .123a↩     .
+    .456       .
+    .789       .
+    .┈┈┈┈┈     .
+  ]
+]
+
+# ctrl-u - delete text from start of line until (but not at) cursor
+
+scenario editor-deletes-to-start-of-line-with-ctrl-u [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on second line, press ctrl-u
+  assume-console [
+    left-click 2, 2
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to start of line
+  screen-should-contain [
+    .          .
+    .123       .
+    .6         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+after <handle-special-character> [
+  {
+    delete-to-start-of-line?:bool <- equal c, 21/ctrl-u
+    break-unless delete-to-start-of-line?
+    <begin-delete-to-start-of-line>
+    deleted-cells:&:duplex-list:char <- delete-to-start-of-line editor
+    <end-delete-to-start-of-line>
+    go-render?:bool <- minimal-render-for-ctrl-u screen, editor, deleted-cells
+    return
+  }
+]
+
+def minimal-render-for-ctrl-u screen:&:screen, editor:&:editor, deleted-cells:&:duplex-list:char -> go-render?:bool, screen:&:screen [
+  local-scope
+  load-inputs
+  curr-column:num <- get *editor, cursor-column:offset
+  # accumulate the current line as text and render it
+  buf:&:buffer:char <- new-buffer 30  # accumulator for the text we need to render
+  curr:&:duplex-list:char <- get *editor, before-cursor:offset
+  i:num <- copy curr-column
+  right:num <- get *editor, right:offset
+  {
+    # if we have a wrapped line, give up and render the whole screen
+    wrap?:bool <- greater-or-equal i, right
+    return-if wrap?, true/go-render
+    curr <- next curr
+    break-unless curr
+    c:char <- get *curr, value:offset
+    b:bool <- equal c, 10
+    break-if b
+    buf <- append buf, c
+    i <- add i, 1
+    loop
+  }
+  # if the line used to be wrapped, give up and render the whole screen
+  num-deleted-cells:num <- length deleted-cells
+  old-row-len:num <- add i, num-deleted-cells
+  left:num <- get *editor, left:offset
+  end:num <- subtract right, left
+  wrap?:bool <- greater-or-equal old-row-len, end
+  return-if wrap?, true/go-render
+  curr-line:text <- buffer-to-array buf
+  curr-row:num <- get *editor, cursor-row:offset
+  render-code screen, curr-line, curr-column, right, curr-row
+  return false/dont-render
+]
+
+def delete-to-start-of-line editor:&:editor -> result:&:duplex-list:char, editor:&:editor [
+  local-scope
+  load-inputs
+  # compute range to delete
+  init:&:duplex-list:char <- get *editor, data:offset
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  update-top-of-screen?:bool <- copy false
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  start:&:duplex-list:char <- copy before-cursor
+  end:&:duplex-list:char <- next before-cursor
+  {
+    at-start-of-text?:bool <- equal start, init
+    break-if at-start-of-text?
+    curr:char <- get *start, value:offset
+    at-start-of-line?:bool <- equal curr, 10/newline
+    break-if at-start-of-line?
+    # if we went past top-of-screen, make a note to update it as well
+    at-top-of-screen?:bool <- equal start, top-of-screen
+    update-top-of-screen?:bool <- or update-top-of-screen?, at-top-of-screen?
+    start <- prev start
+    assert start, [delete-to-start-of-line tried to move before start of text]
+    loop
+  }
+  # snip it out
+  result:&:duplex-list:char <- next start
+  remove-between start, end
+  # update top-of-screen if it's just been invalidated
+  {
+    break-unless update-top-of-screen?
+    put *editor, top-of-screen:offset, start
+  }
+  # adjust cursor
+  before-cursor <- copy start
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  left:num <- get *editor, left:offset
+  *editor <- put *editor, cursor-column:offset, left
+  # if the line wrapped before, we may need to adjust cursor-row as well
+  right:num <- get *editor, right:offset
+  width:num <- subtract right, left
+  num-deleted:num <- length result
+  cursor-row-adjustment:num <- divide-with-remainder num-deleted, width
+  return-unless cursor-row-adjustment
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-row-in-editor:num <- subtract cursor-row, 1  # ignore menubar
+  at-top?:bool <- lesser-or-equal cursor-row-in-editor, cursor-row-adjustment
+  {
+    break-unless at-top?
+    cursor-row <- copy 1  # top of editor, below menubar
+  }
+  {
+    break-if at-top?
+    cursor-row <- subtract cursor-row, cursor-row-adjustment
+  }
+  put *editor, cursor-row:offset, cursor-row
+]
+
+def render-code screen:&:screen, s:text, left:num, right:num, row:num -> row:num, screen:&:screen [
+  local-scope
+  load-inputs
+  return-unless s
+  color:num <- copy 7/white
+  column:num <- copy left
+  screen <- move-cursor screen, row, column
+  screen-height:num <- screen-height screen
+  i:num <- copy 0
+  len:num <- length *s
+  {
+    +next-character
+    done?:bool <- greater-or-equal i, len
+    break-if done?
+    done? <- greater-or-equal row, screen-height
+    break-if done?
+    c:char <- index *s, i
+    <character-c-received>
+    {
+      # newline? move to left rather than 0
+      newline?:bool <- equal c, 10/newline
+      break-unless newline?
+      # clear rest of line in this window
+      {
+        done?:bool <- greater-than column, right
+        break-if done?
+        space:char <- copy 32/space
+        print screen, space
+        column <- add column, 1
+        loop
+      }
+      row <- add row, 1
+      column <- copy left
+      screen <- move-cursor screen, row, column
+      i <- add i, 1
+      loop +next-character
+    }
+    {
+      # at right? wrap.
+      at-right?:bool <- equal column, right
+      break-unless at-right?
+      # print wrap icon
+      wrap-icon:char <- copy 8617/loop-back-to-left
+      print screen, wrap-icon, 245/grey
+      column <- copy left
+      row <- add row, 1
+      screen <- move-cursor screen, row, column
+      # don't increment i
+      loop +next-character
+    }
+    i <- add i, 1
+    print screen, c, color
+    column <- add column, 1
+    loop
+  }
+  was-at-left?:bool <- equal column, left
+  clear-line-until screen, right
+  {
+    break-if was-at-left?
+    row <- add row, 1
+  }
+  move-cursor screen, row, left
+]
+
+scenario editor-deletes-to-start-of-line-with-ctrl-u-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line (no newline before), press ctrl-u
+  assume-console [
+    left-click 1, 2
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to start of line
+  screen-should-contain [
+    .          .
+    .3         .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+scenario editor-deletes-to-start-of-line-with-ctrl-u-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start past end of line, press ctrl-u
+  assume-console [
+    left-click 1, 3
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to start of line
+  screen-should-contain [
+    .          .
+    .          .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+scenario editor-deletes-to-start-of-final-line-with-ctrl-u [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start past end of final line, press ctrl-u
+  assume-console [
+    left-click 2, 3
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to start of line
+  screen-should-contain [
+    .          .
+    .123       .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u [
+  local-scope
+  assume-screen 10/width, 10/height
+  # first line starts out wrapping
+  s:text <- new [123456
+789]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .1234↩     .
+    .56        .
+    .789       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  $clear-trace
+  # ctrl-u enough of the first line that it's no longer wrapping
+  assume-console [
+    left-click 1, 3
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # entire screen needs to be refreshed
+  screen-should-contain [
+    .          .
+    .456       .
+    .789       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  check-trace-count-for-label 45, [print-character]
+]
+
+# sometimes hitting ctrl-u needs to adjust the cursor row
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u-2 [
+  local-scope
+  assume-screen 10/width, 10/height
+  # third line starts out wrapping
+  s:text <- new [1
+2
+345678
+9]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .1         .
+    .2         .
+    .3456↩     .
+    .78        .
+    .9         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # position cursor on screen line after the wrap and hit ctrl-u
+  assume-console [
+    left-click 4, 1  # on '8'
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .1         .
+    .2         .
+    .8         .
+    .9         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # cursor moves up one screen line
+  memory-should-contain [
+    10 <- 3  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+]
+
+# line wrapping twice (taking up 3 screen lines)
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u-3 [
+  local-scope
+  assume-screen 10/width, 10/height
+  # third line starts out wrapping
+  s:text <- new [1
+2
+3456789abcd
+e]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  assume-console [
+    left-click 4, 1  # on '8'
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .1         .
+    .2         .
+    .3456↩     .
+    .789a↩     .
+    .bcd       .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    left-click 5, 1
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .1         .
+    .2         .
+    .cd        .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # make sure we adjusted cursor-row
+  memory-should-contain [
+    10 <- 3  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+]
+
+# adjusting cursor row at the top of the screen
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u-4 [
+  local-scope
+  assume-screen 10/width, 10/height
+  # first line starts out wrapping
+  s:text <- new [1234567
+89]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .1234↩     .
+    .567       .
+    .89        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # position cursor on second screen line (after the wrap) and hit ctrl-u
+  assume-console [
+    left-click 2, 1
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .67        .
+    .89        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  # cursor moves up to screen line 1
+  memory-should-contain [
+    10 <- 1  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+]
+
+# screen begins part-way through a wrapping line
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u-5 [
+  local-scope
+  assume-screen 10/width, 10/height
+  # third line starts out wrapping
+  s:text <- new [1
+2
+345678
+9]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # position the '78' line at the top of the screen
+  assume-console [
+    left-click 4, 1  # on '8'
+    press ctrl-t
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .78        .
+    .9         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    left-click 1, 1
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  # make sure we updated top-of-screen correctly
+  screen-should-contain [
+    .          .
+    .8         .
+    .9         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    10 <- 1  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+  # the entire line is deleted, even the part not shown on screen
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .2         .
+    .8         .
+    .9         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]
+
+# screen begins part-way through a line wrapping twice (taking up 3 screen lines)
+scenario editor-deletes-to-start-of-wrapped-line-with-ctrl-u-6 [
+  local-scope
+  assume-screen 10/width, 10/height
+  # third line starts out wrapping
+  s:text <- new [1
+2
+3456789abcd
+e]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # position the 'bcd' line at the top of the screen
+  assume-console [
+    left-click 4, 1  # on '8'
+    press ctrl-t
+    press ctrl-s  # now on 'c'
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .bcd       .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  assume-console [
+    left-click 1, 1
+    press ctrl-u
+  ]
+  run [
+    editor-event-loop screen, console, e
+    10:num/raw <- get *e, cursor-row:offset
+    11:num/raw <- get *e, cursor-column:offset
+  ]
+  # make sure we updated top-of-screen correctly
+  screen-should-contain [
+    .          .
+    .cd        .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    10 <- 1  # cursor-row
+    11 <- 0  # cursor-column
+  ]
+  # the entire line is deleted, even the part not shown on screen
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .2         .
+    .cd        .
+    .e         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]
+
+# ctrl-k - delete text from cursor to end of line (but not the newline)
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on first line, press ctrl-k
+  assume-console [
+    left-click 1, 1
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to end of line
+  screen-should-contain [
+    .          .
+    .1         .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 9, [print-character]
+]
+
+after <handle-special-character> [
+  {
+    delete-to-end-of-line?:bool <- equal c, 11/ctrl-k
+    break-unless delete-to-end-of-line?
+    <begin-delete-to-end-of-line>
+    deleted-cells:&:duplex-list:char <- delete-to-end-of-line editor
+    <end-delete-to-end-of-line>
+    # checks if we can do a minimal render and if we can it will do a minimal render
+    go-render?:bool <- minimal-render-for-ctrl-k screen, editor, deleted-cells
+    return
+  }
+]
+
+def minimal-render-for-ctrl-k screen:&:screen, editor:&:editor, deleted-cells:&:duplex-list:char -> go-render?:bool, screen:&:screen [
+  local-scope
+  load-inputs
+  # if we deleted nothing, there's nothing to render
+  return-unless deleted-cells, false/dont-render
+  # if the line used to wrap before, give up and render the whole screen
+  curr-column:num <- get *editor, cursor-column:offset
+  num-deleted-cells:num <- length deleted-cells
+  old-row-len:num <- add curr-column, num-deleted-cells
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  end:num <- subtract right, left
+  wrap?:bool <- greater-or-equal old-row-len, end
+  return-if wrap?, true/go-render
+  clear-line-until screen, right
+  return false/dont-render
+]
+
+def delete-to-end-of-line editor:&:editor -> result:&:duplex-list:char, editor:&:editor [
+  local-scope
+  load-inputs
+  # compute range to delete
+  start:&:duplex-list:char <- get *editor, before-cursor:offset
+  end:&:duplex-list:char <- next start
+  {
+    at-end-of-text?:bool <- equal end, null
+    break-if at-end-of-text?
+    curr:char <- get *end, value:offset
+    at-end-of-line?:bool <- equal curr, 10/newline
+    break-if at-end-of-line?
+    end <- next end
+    loop
+  }
+  # snip it out
+  result <- next start
+  remove-between start, end
+]
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k-2 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start on second line (no newline after), press ctrl-k
+  assume-console [
+    left-click 2, 1
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes to end of line
+  screen-should-contain [
+    .          .
+    .123       .
+    .4         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 9, [print-character]
+]
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k-3 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start at end of line
+  assume-console [
+    left-click 1, 2
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes just last character
+  screen-should-contain [
+    .          .
+    .12        .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 8, [print-character]
+]
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k-4 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start past end of line
+  assume-console [
+    left-click 1, 3
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes nothing
+  screen-should-contain [
+    .          .
+    .123       .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 7, [print-character]
+]
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k-5 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start at end of text
+  assume-console [
+    left-click 2, 2
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes just the final character
+  screen-should-contain [
+    .          .
+    .123       .
+    .45        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 8, [print-character]
+]
+
+scenario editor-deletes-to-end-of-line-with-ctrl-k-6 [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [123
+456]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # start past end of text
+  assume-console [
+    left-click 2, 3
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor deletes nothing
+  screen-should-contain [
+    .          .
+    .123       .
+    .456       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # no prints necessary
+  check-trace-count-for-label 0, [print-character]
+]
+
+scenario editor-deletes-to-end-of-wrapped-line-with-ctrl-k [
+  local-scope
+  assume-screen 10/width, 5/height
+  # create an editor with the first line wrapping to a second screen row
+  s:text <- new [1234
+567]
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  $clear-trace
+  # delete all of the first wrapped line
+  assume-console [
+    press ctrl-k
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows an empty unwrapped first line
+  screen-should-contain [
+    .          .
+    .          .
+    .567       .
+    .┈┈┈┈      .
+    .          .
+  ]
+  # entire screen is refreshed
+  check-trace-count-for-label 16, [print-character]
+]
+
+# scroll down if necessary
+
+scenario editor-can-scroll-down-using-arrow-keys [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with >3 lines
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # position cursor at last line, then try to move further down
+  assume-console [
+    left-click 3, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen slides by one line
+  screen-should-contain [
+    .          .
+    .b         .
+    .c         .
+    .d         .
+  ]
+]
+
+after <scroll-down> [
+  trace 10, [app], [scroll down]
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  max:num <- subtract right, left
+  old-top:&:duplex-list:char <- copy top-of-screen
+  top-of-screen <- before-start-of-next-line top-of-screen, max
+  *editor <- put *editor, top-of-screen:offset, top-of-screen
+  no-movement?:bool <- equal old-top, top-of-screen
+  return-if no-movement?, false/don't-render
+]
+
+after <scroll-down2> [
+  trace 10, [app], [scroll down]
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  max:num <- subtract right, left
+  old-top:&:duplex-list:char <- copy top-of-screen
+  top-of-screen <- before-start-of-next-line top-of-screen, max
+  *editor <- put *editor, top-of-screen:offset, top-of-screen
+  no-movement?:bool <- equal old-top, top-of-screen
+  return-if no-movement?
+]
+
+# Takes a pointer into the doubly-linked list, scans ahead at most 'max'
+# positions until the next newline.
+# Returns original if no next newline.
+# Beware: never return null pointer.
+def before-start-of-next-line original:&:duplex-list:char, max:num -> curr:&:duplex-list:char [
+  local-scope
+  load-inputs
+  count:num <- copy 0
+  curr:&:duplex-list:char <- copy original
+  # skip the initial newline if it exists
+  {
+    c:char <- get *curr, value:offset
+    at-newline?:bool <- equal c, 10/newline
+    break-unless at-newline?
+    curr <- next curr
+    count <- add count, 1
+  }
+  {
+    return-unless curr, original
+    done?:bool <- greater-or-equal count, max
+    break-if done?
+    c:char <- get *curr, value:offset
+    at-newline?:bool <- equal c, 10/newline
+    break-if at-newline?
+    curr <- next curr
+    count <- add count, 1
+    loop
+  }
+  return-unless curr, original
+  return curr
+]
+
+scenario editor-scrolls-down-past-wrapped-line-using-arrow-keys [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with a long, wrapped line and more than a screen of
+  # other lines
+  s:text <- new [abcdef
+g
+h
+i]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .g         .
+  ]
+  # position cursor at last line, then try to move further down
+  assume-console [
+    left-click 3, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .ef        .
+    .g         .
+    .h         .
+  ]
+]
+
+scenario editor-scrolls-down-past-wrapped-line-using-arrow-keys-2 [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # editor starts with a long line wrapping twice
+  s:text <- new [abcdefghij
+k
+l
+m]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  # position cursor at last line, then try to move further down
+  assume-console [
+    left-click 3, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line containing a wrap icon
+  screen-should-contain [
+    .          .
+    .efgh↩     .
+    .ij        .
+    .k         .
+  ]
+  # scroll down again
+  assume-console [
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .ij        .
+    .k         .
+    .l         .
+  ]
+]
+
+scenario editor-scrolls-down-when-line-wraps [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 5/width, 4/height
+  # editor contains a long line in the third line
+  s:text <- new [a
+b
+cdef]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  # position cursor at end, type a character
+  assume-console [
+    left-click 3, 4
+    type [g]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .cdef↩.
+    .g    .
+  ]
+  memory-should-contain [
+    3 <- 3
+    4 <- 1
+  ]
+]
+
+scenario editor-stops-scrolling-once-bottom-is-visible [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with 2 lines
+  s:text <- new [a
+b]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  # position cursor at last line, then try to move further down
+  assume-console [
+    left-click 3, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # no change since the bottom border was already visible
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-scrolls-down-on-newline [
+  local-scope
+  assume-screen 5/width, 4/height
+  # position cursor after last line and type newline
+  s:text <- new [a
+b
+c]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  assume-console [
+    left-click 3, 4
+    type [
+]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .c    .
+    .     .
+  ]
+  memory-should-contain [
+    3 <- 3
+    4 <- 0
+  ]
+]
+
+scenario editor-scrolls-down-on-right-arrow [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 5/width, 4/height
+  # editor contains a wrapped line
+  s:text <- new [a
+b
+cdefgh]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  # position cursor at end of screen and try to move right
+  assume-console [
+    left-click 3, 3
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .cdef↩.
+    .gh   .
+  ]
+  memory-should-contain [
+    3 <- 3
+    4 <- 0
+  ]
+]
+
+scenario editor-scrolls-down-on-right-arrow-2 [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 5/width, 4/height
+  # editor contains more lines than can fit on screen
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  # position cursor at end of screen and try to move right
+  assume-console [
+    left-click 3, 3
+    press right-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .c    .
+    .d    .
+  ]
+  memory-should-contain [
+    3 <- 3
+    4 <- 0
+  ]
+]
+
+scenario editor-scrolls-at-end-on-down-arrow [
+  local-scope
+  assume-screen 10/width, 5/height
+  s:text <- new [abc
+de]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  # try to move down past end of text
+  assume-console [
+    left-click 2, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # no change
+  memory-should-contain [
+    3 <- 2
+    4 <- 0
+  ]
+]
+
+scenario editor-combines-page-and-line-scroll [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with a few pages of lines
+  s:text <- new [a
+b
+c
+d
+e
+f
+g]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # scroll down one page and one line
+  assume-console [
+    press page-down
+    left-click 3, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen scrolls down 3 lines
+  screen-should-contain [
+    .          .
+    .d         .
+    .e         .
+    .f         .
+  ]
+]
+
+# scroll up if necessary
+
+scenario editor-can-scroll-up-using-arrow-keys [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with >3 lines
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # position cursor at top of second page, then try to move up
+  assume-console [
+    press page-down
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen slides by one line
+  screen-should-contain [
+    .          .
+    .b         .
+    .c         .
+    .d         .
+  ]
+]
+
+after <scroll-up> [
+  trace 10, [app], [scroll up]
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  old-top:&:duplex-list:char <- copy top-of-screen
+  top-of-screen <- before-previous-screen-line top-of-screen, editor
+  *editor <- put *editor, top-of-screen:offset, top-of-screen
+  no-movement?:bool <- equal old-top, top-of-screen
+  return-if no-movement?, false/don't-render
+]
+
+scenario editor-scrolls-up-past-wrapped-line-using-arrow-keys [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with a long, wrapped line and more than a screen of
+  # other lines
+  s:text <- new [abcdef
+g
+h
+i]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .ef        .
+    .g         .
+  ]
+  # position cursor at top of second page, just below wrapped line
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .g         .
+    .h         .
+    .i         .
+  ]
+  # now move up one line
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .ef        .
+    .g         .
+    .h         .
+  ]
+]
+
+scenario editor-scrolls-up-past-wrapped-line-using-arrow-keys-2 [
+  local-scope
+  # screen has 1 line for menu + 4 lines
+  assume-screen 10/width, 5/height
+  # editor starts with a long line wrapping twice, occupying 3 of the 4 lines
+  s:text <- new [abcdefghij
+k
+l
+m]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # position cursor at top of second page
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .k         .
+    .l         .
+    .m         .
+    .┈┈┈┈┈     .
+  ]
+  # move up one line
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .ij        .
+    .k         .
+    .l         .
+    .m         .
+  ]
+  # move up a second line
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .efgh↩     .
+    .ij        .
+    .k         .
+    .l         .
+  ]
+  # move up a third line
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .abcd↩     .
+    .efgh↩     .
+    .ij        .
+    .k         .
+  ]
+]
+
+# same as editor-scrolls-up-past-wrapped-line-using-arrow-keys but length
+# slightly off, just to prevent over-training
+scenario editor-scrolls-up-past-wrapped-line-using-arrow-keys-3 [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with a long, wrapped line and more than a screen of
+  # other lines
+  s:text <- new [abcdef
+g
+h
+i]
+  e:&:editor <- new-editor s, 0/left, 6/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcde↩    .
+    .f         .
+    .g         .
+  ]
+  # position cursor at top of second page, just below wrapped line
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .g         .
+    .h         .
+    .i         .
+  ]
+  # now move up one line
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows partial wrapped line
+  screen-should-contain [
+    .          .
+    .f         .
+    .g         .
+    .h         .
+  ]
+]
+
+# check empty lines
+scenario editor-scrolls-up-past-wrapped-line-using-arrow-keys-4 [
+  local-scope
+  assume-screen 10/width, 4/height
+  # initialize editor with some lines around an empty line
+  s:text <- new [a
+b
+
+c
+d
+e]
+  e:&:editor <- new-editor s, 0/left, 6/right
+  editor-render screen, e
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .          .
+    .c         .
+    .d         .
+  ]
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .d         .
+    .e         .
+    .┈┈┈┈┈┈    .
+  ]
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .          .
+    .c         .
+    .d         .
+  ]
+]
+
+scenario editor-scrolls-up-on-left-arrow [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 5/width, 4/height
+  # editor contains >3 lines
+  s:text <- new [a
+b
+c
+d
+e]
+  e:&:editor <- new-editor s, 0/left, 5/right
+  editor-render screen, e
+  # position cursor at top of second page
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .     .
+    .c    .
+    .d    .
+    .e    .
+  ]
+  # now try to move left
+  assume-console [
+    press left-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .c    .
+    .d    .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+]
+
+scenario editor-can-scroll-up-to-start-of-file [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with >3 lines
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # position cursor at top of second page, then try to move up to start of
+  # text
+  assume-console [
+    press page-down
+    press up-arrow
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen slides by one line
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # try to move up again
+  assume-console [
+    press up-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen remains unchanged
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+]
+
+# ctrl-f/page-down - render next page if it exists
+
+scenario editor-can-scroll [
+  local-scope
+  assume-screen 10/width, 4/height
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows next page
+  screen-should-contain [
+    .          .
+    .c         .
+    .d         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+after <handle-special-character> [
+  {
+    page-down?:bool <- equal c, 6/ctrl-f
+    break-unless page-down?
+    old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+    <begin-move-cursor>
+    page-down editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+    movement?:bool <- not-equal top-of-screen, old-top
+    return movement?/go-render
+  }
+]
+
+after <handle-special-key> [
+  {
+    page-down?:bool <- equal k, 65518/page-down
+    break-unless page-down?
+    old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+    <begin-move-cursor>
+    page-down editor
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+    movement?:bool <- not-equal top-of-screen, old-top
+    return movement?/go-render
+  }
+]
+
+# page-down skips entire wrapped lines, so it can't scroll past lines
+# taking up the entire screen
+def page-down editor:&:editor -> editor:&:editor [
+  local-scope
+  load-inputs
+  # if editor contents don't overflow screen, do nothing
+  bottom-of-screen:&:duplex-list:char <- get *editor, bottom-of-screen:offset
+  return-unless bottom-of-screen
+  # if not, position cursor at final character
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  before-cursor:&:duplex-list:char <- prev bottom-of-screen
+  *editor <- put *editor, before-cursor:offset, before-cursor
+  # keep one line in common with previous page
+  {
+    last:char <- get *before-cursor, value:offset
+    newline?:bool <- equal last, 10/newline
+    break-unless newline?:bool
+    before-cursor <- prev before-cursor
+    *editor <- put *editor, before-cursor:offset, before-cursor
+  }
+  # move cursor and top-of-screen to start of that line
+  move-to-start-of-line editor
+  before-cursor <- get *editor, before-cursor:offset
+  *editor <- put *editor, top-of-screen:offset, before-cursor
+]
+
+# jump to previous newline
+def move-to-start-of-line editor:&:editor -> editor:&:editor [
+  local-scope
+  load-inputs
+  # update cursor column
+  left:num <- get *editor, left:offset
+  cursor-column:num <- copy left
+  *editor <- put *editor, cursor-column:offset, cursor-column
+  # update before-cursor
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  init:&:duplex-list:char <- get *editor, data:offset
+  # while not at start of line, move
+  {
+    at-start-of-text?:bool <- equal before-cursor, init
+    break-if at-start-of-text?
+    prev:char <- get *before-cursor, value:offset
+    at-start-of-line?:bool <- equal prev, 10/newline
+    break-if at-start-of-line?
+    before-cursor <- prev before-cursor
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    assert before-cursor, [move-to-start-of-line tried to move before start of text]
+    loop
+  }
+]
+
+scenario editor-does-not-scroll-past-end [
+  local-scope
+  assume-screen 10/width, 4/height
+  s:text <- new [a
+b]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen remains unmodified
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-starts-next-page-at-start-of-wrapped-line [
+  local-scope
+  # screen has 1 line for menu + 3 lines for text
+  assume-screen 10/width, 4/height
+  # editor contains a long last line
+  s:text <- new [a
+b
+cdefgh]
+  # editor screen triggers wrap of last line
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  # some part of last line is not displayed
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .cde↩      .
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows entire wrapped line
+  screen-should-contain [
+    .          .
+    .cde↩      .
+    .fgh       .
+    .┈┈┈┈      .
+  ]
+]
+
+scenario editor-starts-next-page-at-start-of-wrapped-line-2 [
+  local-scope
+  # screen has 1 line for menu + 3 lines for text
+  assume-screen 10/width, 4/height
+  # editor contains a very long line that occupies last two lines of screen
+  # and still has something left over
+  s:text <- new [a
+bcdefgh]
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  # some part of last line is not displayed
+  screen-should-contain [
+    .          .
+    .a         .
+    .bcd↩      .
+    .efg↩      .
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows entire wrapped line
+  screen-should-contain [
+    .          .
+    .bcd↩      .
+    .efg↩      .
+    .h         .
+  ]
+]
+
+# ctrl-b/page-up - render previous page if it exists
+
+scenario editor-can-scroll-up [
+  local-scope
+  assume-screen 10/width, 4/height
+  s:text <- new [a
+b
+c
+d]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows next page
+  screen-should-contain [
+    .          .
+    .c         .
+    .d         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  # scroll back up
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows original page again
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+]
+
+after <handle-special-character> [
+  {
+    page-up?:bool <- equal c, 2/ctrl-b
+    break-unless page-up?
+    old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+    <begin-move-cursor>
+    editor <- page-up editor, screen-height
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+    movement?:bool <- not-equal top-of-screen, old-top
+    return movement?/go-render
+  }
+]
+
+after <handle-special-key> [
+  {
+    page-up?:bool <- equal k, 65519/page-up
+    break-unless page-up?
+    old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+    <begin-move-cursor>
+    editor <- page-up editor, screen-height
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+    movement?:bool <- not-equal top-of-screen, old-top
+    # don't bother re-rendering if nothing changed. todo: test this
+    return movement?/go-render
+  }
+]
+
+def page-up editor:&:editor, screen-height:num -> editor:&:editor [
+  local-scope
+  load-inputs
+  max:num <- subtract screen-height, 1/menu-bar, 1/overlapping-line
+  count:num <- copy 0
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  {
+    done?:bool <- greater-or-equal count, max
+    break-if done?
+    prev:&:duplex-list:char <- before-previous-screen-line top-of-screen, editor
+    break-unless prev
+    top-of-screen <- copy prev
+    *editor <- put *editor, top-of-screen:offset, top-of-screen
+    count <- add count, 1
+    loop
+  }
+]
+
+scenario editor-can-scroll-up-multiple-pages [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 10/width, 4/height
+  # initialize editor with 8 lines
+  s:text <- new [a
+b
+c
+d
+e
+f
+g
+h]
+  e:&:editor <- new-editor s, 0/left, 10/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+  # scroll down two pages
+  assume-console [
+    press page-down
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows third page
+  screen-should-contain [
+    .          .
+    .e         .
+    .f         .
+    .g         .
+  ]
+  # scroll up
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows second page
+  screen-should-contain [
+    .          .
+    .c         .
+    .d         .
+    .e         .
+  ]
+  # scroll up again
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows original page again
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+  ]
+]
+
+scenario editor-can-scroll-up-wrapped-lines [
+  local-scope
+  # screen has 1 line for menu + 5 lines for text
+  assume-screen 10/width, 6/height
+  # editor contains a long line in the first page
+  s:text <- new [a
+b
+cdefgh
+i
+j
+k
+l
+m
+n
+o]
+  # editor screen triggers wrap of last line
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  # some part of last line is not displayed
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .cde↩      .
+    .fgh       .
+    .i         .
+  ]
+  # scroll down a page and a line
+  assume-console [
+    press page-down
+    left-click 5, 0
+    press down-arrow
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows entire wrapped line
+  screen-should-contain [
+    .          .
+    .j         .
+    .k         .
+    .l         .
+    .m         .
+    .n         .
+  ]
+  # now scroll up one page
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen resets
+  screen-should-contain [
+    .          .
+    .b         .
+    .cde↩      .
+    .fgh       .
+    .i         .
+    .j         .
+  ]
+]
+
+scenario editor-can-scroll-up-wrapped-lines-2 [
+  local-scope
+  # screen has 1 line for menu + 3 lines for text
+  assume-screen 10/width, 4/height
+  # editor contains a very long line that occupies last two lines of screen
+  # and still has something left over
+  s:text <- new [a
+bcdefgh]
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  # some part of last line is not displayed
+  screen-should-contain [
+    .          .
+    .a         .
+    .bcd↩      .
+    .efg↩      .
+  ]
+  # scroll down
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen shows entire wrapped line
+  screen-should-contain [
+    .          .
+    .bcd↩      .
+    .efg↩      .
+    .h         .
+  ]
+  # scroll back up
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen resets
+  screen-should-contain [
+    .          .
+    .a         .
+    .bcd↩      .
+    .efg↩      .
+  ]
+]
+
+scenario editor-can-scroll-up-past-nonempty-lines [
+  local-scope
+  assume-screen 10/width, 4/height
+  # text with empty line in second screen
+  s:text <- new [axx
+bxx
+cxx
+dxx
+exx
+fxx
+gxx
+hxx
+]
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .axx       .
+    .bxx       .
+    .cxx       .
+  ]
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .cxx       .
+    .dxx       .
+    .exx       .
+  ]
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .exx       .
+    .fxx       .
+    .gxx       .
+  ]
+  # scroll back up past empty line
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .cxx       .
+    .dxx       .
+    .exx       .
+  ]
+]
+
+scenario editor-can-scroll-up-past-empty-lines [
+  local-scope
+  assume-screen 10/width, 4/height
+  # text with empty line in second screen
+  s:text <- new [axy
+bxy
+cxy
+
+dxy
+exy
+fxy
+gxy
+]
+  e:&:editor <- new-editor s, 0/left, 4/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .axy       .
+    .bxy       .
+    .cxy       .
+  ]
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .cxy       .
+    .          .
+    .dxy       .
+  ]
+  assume-console [
+    press page-down
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .dxy       .
+    .exy       .
+    .fxy       .
+  ]
+  # scroll back up past empty line
+  assume-console [
+    press page-up
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .cxy       .
+    .          .
+    .dxy       .
+  ]
+]
+
+# ctrl-s - scroll up by one line
+# todo: scenarios
+
+after <handle-special-character> [
+  {
+    scroll-up?:bool <- equal c, 19/ctrl-s
+    break-unless scroll-up?
+    <begin-move-cursor>
+    go-render?:bool, editor <- line-up editor, screen-height
+    undo-coalesce-tag:num <- copy 5/line-up
+    <end-move-cursor>
+    return go-render?
+  }
+]
+
+def line-up editor:&:editor, screen-height:num -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  left:num <- get *editor, left:offset
+  right:num <- get *editor, right:offset
+  max:num <- subtract right, left
+  old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+  new-top:&:duplex-list:char <- before-start-of-next-line old-top, max
+  movement?:bool <- not-equal old-top, new-top
+  {
+    break-unless movement?
+    *editor <- put *editor, top-of-screen:offset, new-top
+  }
+  return movement?
+]
+
+# ctrl-x - scroll down by one line
+# todo: scenarios
+
+after <handle-special-character> [
+  {
+    scroll-down?:bool <- equal c, 24/ctrl-x
+    break-unless scroll-down?
+    <begin-move-cursor>
+    go-render?:bool, editor <- line-down editor, screen-height
+    undo-coalesce-tag:num <- copy 6/line-down
+    <end-move-cursor>
+    return go-render?
+  }
+]
+
+def line-down editor:&:editor, screen-height:num -> go-render?:bool, editor:&:editor [
+  local-scope
+  load-inputs
+  old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+  new-top:&:duplex-list:char <- before-previous-screen-line old-top, editor
+  movement?:bool <- not-equal old-top, new-top
+  {
+    break-unless movement?
+    *editor <- put *editor, top-of-screen:offset, new-top
+  }
+  return movement?
+]
+
+# ctrl-t - move current line to top of screen
+# todo: scenarios
+
+after <handle-special-character> [
+  {
+    scroll-down?:bool <- equal c, 20/ctrl-t
+    break-unless scroll-down?
+    <begin-move-cursor>
+    old-top:&:duplex-list:char <- get *editor, top-of-screen:offset
+    cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+    cursor <- next cursor
+    new-top:&:duplex-list:char <- before-previous-screen-line cursor, editor
+    *editor <- put *editor, top-of-screen:offset, new-top
+    *editor <- put *editor, cursor-row:offset, 1
+    go-render?:bool <- not-equal new-top, old-top
+    undo-coalesce-tag:num <- copy 0/never
+    <end-move-cursor>
+    return go-render?
+  }
+]
+
+# ctrl-/ - comment/uncomment current line
+
+after <handle-special-character> [
+  {
+    comment-toggle?:bool <- equal c, 31/ctrl-slash
+    break-unless comment-toggle?
+    cursor-column:num <- get *editor, cursor-column:offset
+    data:&:duplex-list:char <- get *editor, data:offset
+    <begin-insert-character>
+    before-line-start:&:duplex-list:char <- before-start-of-screen-line editor
+    line-start:&:duplex-list:char <- next before-line-start
+    commented-out?:bool <- match line-start, [#? ]  # comment prefix
+    {
+      break-unless commented-out?
+      # uncomment
+      data <- remove line-start, 3/length-comment-prefix, data
+      cursor-column <- subtract cursor-column, 3/length-comment-prefix
+      *editor <- put *editor, cursor-column:offset, cursor-column
+      go-render? <- render-line-from-start screen, editor, 3/size-of-comment-leader
+    }
+    {
+      break-if commented-out?
+      # comment
+      insert before-line-start, [#? ]
+      cursor-column <- add cursor-column, 3/length-comment-prefix
+      *editor <- put *editor, cursor-column:offset, cursor-column
+      go-render? <- render-line-from-start screen, editor, 0
+    }
+    <end-insert-character>
+    return
+  }
+]
+
+# Render just from the start of the current line, and only if it wasn't
+# wrapping before (include margin) and isn't wrapping now. Otherwise just tell
+# the caller to go-render? the entire screen.
+def render-line-from-start screen:&:screen, editor:&:editor, right-margin:num -> go-render?:bool, screen:&:screen [
+  local-scope
+  load-inputs
+  before-line-start:&:duplex-list:char <- before-start-of-screen-line editor
+  line-start:&:duplex-list:char <- next before-line-start
+  color:num <- copy 7/white
+  left:num <- get *editor, left:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  screen <- move-cursor screen, cursor-row, left
+  right:num <- get *editor, right:offset
+  end:num <- subtract right, right-margin
+  i:num <- copy 0
+  curr:&:duplex-list:char <- copy line-start
+  {
+    render-all?:bool <- greater-or-equal i, end
+    return-if render-all?, true/go-render
+    break-unless curr
+    c:char <- get *curr, value:offset
+    newline?:bool <- equal c, 10/newline
+    break-if newline?
+    color <- get-color color, c
+    print screen, c, color
+    curr <- next curr
+    i <- add i, 1
+    loop
+  }
+  clear-line-until screen, right
+  return false/dont-render
+]
+
+def before-start-of-screen-line editor:&:editor -> result:&:duplex-list:char [
+  local-scope
+  load-inputs
+  cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  {
+    next:&:duplex-list:char <- next cursor
+    break-unless next
+    cursor <- copy next
+  }
+  result <- before-previous-screen-line cursor, editor
+]
+
+scenario editor-comments-empty-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 5/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .#?        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 3
+  ]
+  check-trace-count-for-label 5, [print-character]
+]
+
+scenario editor-comments-at-start-of-contents [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [ab], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .#? ab     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 3
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+scenario editor-comments-at-end-of-contents [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [ab], 0/left, 10/right
+  editor-render screen, e
+  $clear-trace
+  assume-console [
+    left-click 1, 7
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .#? ab     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    4 <- 1
+    5 <- 5
+  ]
+  check-trace-count-for-label 10, [print-character]
+  # toggle to uncomment
+  $clear-trace
+  assume-console [
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+    4:num/raw <- get *e, cursor-row:offset
+    5:num/raw <- get *e, cursor-column:offset
+  ]
+  screen-should-contain [
+    .          .
+    .ab        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  check-trace-count-for-label 10, [print-character]
+]
+
+scenario editor-comments-almost-wrapping-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  # editor starts out with a non-wrapping line
+  e:&:editor <- new-editor [abcd], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .abcd      .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  $clear-trace
+  # on commenting the line is now wrapped
+  assume-console [
+    left-click 1, 7
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .#? a↩     .
+    .bcd       .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]
+
+scenario editor-uncomments-just-wrapping-line [
+  local-scope
+  assume-screen 10/width, 5/height
+  # editor starts out with a comment that wraps the line
+  e:&:editor <- new-editor [#? ab], 0/left, 5/right
+  editor-render screen, e
+  screen-should-contain [
+    .          .
+    .#? a↩     .
+    .b         .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+  $clear-trace
+  # on uncommenting the line is no longer wrapped
+  assume-console [
+    left-click 1, 7
+    press ctrl-slash
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .ab        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]
diff --git a/archive/2.vm/edit/004-programming-environment.mu b/archive/2.vm/edit/004-programming-environment.mu
new file mode 100644
index 00000000..dec8a2d5
--- /dev/null
+++ b/archive/2.vm/edit/004-programming-environment.mu
@@ -0,0 +1,549 @@
+## putting the environment together out of editors
+#
+# Consists of one editor on the left for recipes and one on the right for the
+# sandbox.
+
+def! main [
+  local-scope
+  open-console
+  clear-screen null/screen  # non-scrolling app
+  env:&:environment <- new-programming-environment null/filesystem, null/screen
+  render-all null/screen, env, render
+  event-loop null/screen, null/console, env, null/filesystem
+]
+
+container environment [
+  recipes:&:editor
+  current-sandbox:&:editor
+  sandbox-in-focus?:bool  # false => cursor in recipes; true => cursor in current-sandbox
+]
+
+def new-programming-environment resources:&:resources, screen:&:screen, test-sandbox-editor-contents:text -> result:&:environment [
+  local-scope
+  load-inputs
+  width:num <- screen-width screen
+  result <- new environment:type
+  # recipe editor on the left
+  initial-recipe-contents:text <- slurp resources, [lesson/recipes.mu]  # ignore errors
+  divider:num, _ <- divide-with-remainder width, 2
+  recipes:&:editor <- new-editor initial-recipe-contents, 0/left, divider/right
+  # sandbox editor on the right
+  sandbox-left:num <- add divider, 1
+  current-sandbox:&:editor <- new-editor test-sandbox-editor-contents, sandbox-left, width/right
+  *result <- put *result, recipes:offset, recipes
+  *result <- put *result, current-sandbox:offset, current-sandbox
+  *result <- put *result, sandbox-in-focus?:offset, false
+  <programming-environment-initialization>
+]
+
+def event-loop screen:&:screen, console:&:console, env:&:environment, resources:&:resources -> screen:&:screen, console:&:console, env:&:environment, resources:&:resources [
+  local-scope
+  load-inputs
+  recipes:&:editor <- get *env, recipes:offset
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  sandbox-in-focus?:bool <- get *env, sandbox-in-focus?:offset
+  # if we fall behind we'll stop updating the screen, but then we have to
+  # render the entire screen when we catch up.
+  # todo: test this
+  render-recipes-on-no-more-events?:bool <- copy false
+  render-sandboxes-on-no-more-events?:bool <- copy false
+  {
+    # looping over each (keyboard or touch) event as it occurs
+    +next-event
+    e:event, found?:bool, quit?:bool, console <- read-event console
+    loop-unless found?
+    break-if quit?  # only in tests
+    trace 10, [app], [next-event]
+    <handle-event>
+    # check for global events that will trigger regardless of which editor has focus
+    {
+      k:num, is-keycode?:bool <- maybe-convert e:event, keycode:variant
+      break-unless is-keycode?
+      <global-keypress>
+    }
+    {
+      c:char, is-unicode?:bool <- maybe-convert e:event, text:variant
+      break-unless is-unicode?
+      <global-type>
+    }
+    # 'touch' event - send to both sides, see what picks it up
+    {
+      t:touch-event, is-touch?:bool <- maybe-convert e:event, touch:variant
+      break-unless is-touch?
+      # ignore all but 'left-click' events for now
+      # todo: test this
+      touch-type:num <- get t, type:offset
+      is-left-click?:bool <- equal touch-type, 65513/mouse-left
+      loop-unless is-left-click?, +next-event
+      click-row:num <- get t, row:offset
+      click-column:num <- get t, column:offset
+      # later exceptions for non-editor touches will go here
+      <global-touch>
+      # send to both editors
+      _ <- move-cursor recipes, screen, t
+      sandbox-in-focus?:bool <- move-cursor current-sandbox, screen, t
+      *env <- put *env, sandbox-in-focus?:offset, sandbox-in-focus?
+      screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+      loop +next-event
+    }
+    # 'resize' event - redraw editor
+    # todo: test this after supporting resize in assume-console
+    {
+      r:resize-event, is-resize?:bool <- maybe-convert e:event, resize:variant
+      break-unless is-resize?
+      env, screen <- resize screen, env
+      screen <- render-all screen, env, render-without-moving-cursor
+      loop +next-event
+    }
+    # if it's not global and not a touch event, send to appropriate editor
+    {
+      sandbox-in-focus?:bool <- get *env, sandbox-in-focus?:offset
+      {
+        break-if sandbox-in-focus?
+        render?:bool <- handle-keyboard-event screen, recipes, e:event
+        render-recipes-on-no-more-events? <- or render?, render-recipes-on-no-more-events?
+      }
+      {
+        break-unless sandbox-in-focus?
+        render?:bool <- handle-keyboard-event screen, current-sandbox, e:event
+        render-sandboxes-on-no-more-events? <- or render?, render-sandboxes-on-no-more-events?
+      }
+      more-events?:bool <- has-more-events? console
+      {
+        break-if more-events?
+        {
+          break-unless render-recipes-on-no-more-events?
+          render-recipes-on-no-more-events? <- copy false
+          screen <- render-recipes screen, env, render
+        }
+        {
+          break-unless render-sandboxes-on-no-more-events?
+          render-sandboxes-on-no-more-events? <- copy false
+          screen <- render-sandbox-side screen, env, render
+        }
+      }
+      screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    }
+    loop
+  }
+]
+
+def resize screen:&:screen, env:&:environment -> env:&:environment, screen:&:screen [
+  local-scope
+  load-inputs
+  clear-screen screen  # update screen dimensions
+  width:num <- screen-width screen
+  divider:num, _ <- divide-with-remainder width, 2
+  # update recipe editor
+  recipes:&:editor <- get *env, recipes:offset
+  right:num <- subtract divider, 1
+  *recipes <- put *recipes, right:offset, right
+  # reset cursor (later we'll try to preserve its position)
+  *recipes <- put *recipes, cursor-row:offset, 1
+  *recipes <- put *recipes, cursor-column:offset, 0
+  # update sandbox editor
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  left:num <- add divider, 1
+  *current-sandbox <- put *current-sandbox, left:offset, left
+  right:num <- subtract width, 1
+  *current-sandbox <- put *current-sandbox, right:offset, right
+  # reset cursor (later we'll try to preserve its position)
+  *current-sandbox <- put *current-sandbox, cursor-row:offset, 1
+  *current-sandbox <- put *current-sandbox, cursor-column:offset, left
+]
+
+# Variant of 'render' that updates cursor-row and cursor-column based on
+# before-cursor (rather than the other way around). If before-cursor moves
+# off-screen, it resets cursor-row and cursor-column.
+def render-without-moving-cursor screen:&:screen, editor:&:editor -> last-row:num, last-column:num, screen:&:screen, editor:&:editor [
+  local-scope
+  load-inputs
+  return-unless editor, 1/top, 0/left
+  left:num <- get *editor, left:offset
+  screen-height:num <- screen-height screen
+  right:num <- get *editor, right:offset
+  curr:&:duplex-list:char <- get *editor, top-of-screen:offset
+  prev:&:duplex-list:char <- copy curr  # just in case curr becomes null and we can't compute prev
+  curr <- next curr
+  color:num <- copy 7/white
+  row:num <- copy 1/top
+  column:num <- copy left
+  # save before-cursor
+  old-before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  # initialze cursor-row/cursor-column/before-cursor to the top of the screen
+  # by default
+  *editor <- put *editor, cursor-row:offset, row
+  *editor <- put *editor, cursor-column:offset, column
+  top-of-screen:&:duplex-list:char <- get *editor, top-of-screen:offset
+  *editor <- put *editor, before-cursor:offset, top-of-screen
+  screen <- move-cursor screen, row, column
+  {
+    +next-character
+    break-unless curr
+    off-screen?:bool <- greater-or-equal row, screen-height
+    break-if off-screen?
+    # if we find old-before-cursor still on the new resized screen, update
+    # editor.cursor-row and editor.cursor-column based on
+    # old-before-cursor
+    {
+      at-cursor?:bool <- equal old-before-cursor, prev
+      break-unless at-cursor?
+      *editor <- put *editor, cursor-row:offset, row
+      *editor <- put *editor, cursor-column:offset, column
+      *editor <- put *editor, before-cursor:offset, old-before-cursor
+    }
+    c:char <- get *curr, value:offset
+    <character-c-received>
+    {
+      # newline? move to left rather than 0
+      newline?:bool <- equal c, 10/newline
+      break-unless newline?
+      # clear rest of line in this window
+      clear-line-until screen, right
+      # skip to next line
+      row <- add row, 1
+      column <- copy left
+      screen <- move-cursor screen, row, column
+      curr <- next curr
+      prev <- next prev
+      loop +next-character
+    }
+    {
+      # at right? wrap. even if there's only one more letter left; we need
+      # room for clicking on the cursor after it.
+      at-right?:bool <- equal column, right
+      break-unless at-right?
+      # print wrap icon
+      wrap-icon:char <- copy 8617/loop-back-to-left
+      print screen, wrap-icon, 245/grey
+      column <- copy left
+      row <- add row, 1
+      screen <- move-cursor screen, row, column
+      # don't increment curr
+      loop +next-character
+    }
+    print screen, c, color
+    curr <- next curr
+    prev <- next prev
+    column <- add column, 1
+    loop
+  }
+  # save first character off-screen
+  *editor <- put *editor, bottom-of-screen:offset, curr
+  *editor <- put *editor, bottom:offset, row
+  return row, column
+]
+
+scenario point-at-multiple-editors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 30/width, 5/height
+  # initialize both halves of screen
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |abc|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [def]  # contents of sandbox editor
+  # focus on both sides
+  assume-console [
+    left-click 1, 1
+    left-click 1, 17
+  ]
+  # check cursor column in each
+  run [
+    event-loop screen, console, env, resources
+    recipes:&:editor <- get *env, recipes:offset
+    5:num/raw <- get *recipes, cursor-column:offset
+    sandbox:&:editor <- get *env, current-sandbox:offset
+    7:num/raw <- get *sandbox, cursor-column:offset
+  ]
+  memory-should-contain [
+    5 <- 1
+    7 <- 17
+  ]
+]
+
+scenario edit-multiple-editors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 30/width, 5/height
+  # initialize both halves of screen
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |abc|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [def]  # contents of sandbox
+  render-all screen, env, render
+  # type one letter in each of them
+  assume-console [
+    left-click 1, 1
+    type [0]
+    left-click 1, 17
+    type [1]
+  ]
+  run [
+    event-loop screen, console, env, resources
+    recipes:&:editor <- get *env, recipes:offset
+    5:num/raw <- get *recipes, cursor-column:offset
+    sandbox:&:editor <- get *env, current-sandbox:offset
+    7:num/raw <- get *sandbox, cursor-column:offset
+  ]
+  screen-should-contain [
+    .           run (F4)           .  # this line has a different background, but we don't test that yet
+    .a0bc           ┊d1ef          .
+    .               ┊──────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊              .
+    .               ┊              .
+  ]
+  memory-should-contain [
+    5 <- 2  # cursor column of recipe editor
+    7 <- 18  # cursor column of sandbox editor
+  ]
+  # show the cursor at the right window
+  run [
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  screen-should-contain [
+    .           run (F4)           .
+    .a0bc           ┊d1␣f          .
+    .               ┊──────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊              .
+    .               ┊              .
+  ]
+]
+
+scenario editor-in-focus-keeps-cursor [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 30/width, 5/height
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |abc|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [def]
+  render-all screen, env, render
+  # initialize programming environment and highlight cursor
+  assume-console []
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # is cursor at the right place?
+  screen-should-contain [
+    .           run (F4)           .
+    .␣bc            ┊def           .
+    .               ┊──────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊              .
+    .               ┊              .
+  ]
+  # now try typing a letter
+  assume-console [
+    type [z]
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # cursor should still be right
+  screen-should-contain [
+    .           run (F4)           .
+    .z␣bc           ┊def           .
+    .               ┊──────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊              .
+    .               ┊              .
+  ]
+]
+
+scenario backspace-in-sandbox-editor-joins-lines [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 30/width, 5/height
+  assume-resources [
+  ]
+  # initialize sandbox side with two lines
+  test-sandbox-editor-contents:text <- new [abc
+def]
+  env:&:environment <- new-programming-environment resources, screen, test-sandbox-editor-contents
+  render-all screen, env, render
+  screen-should-contain [
+    .           run (F4)           .
+    .               ┊abc           .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊def           .
+    .               ┊──────────────.
+    .               ┊              .
+  ]
+  # position cursor at start of second line and hit backspace
+  assume-console [
+    left-click 2, 16
+    press backspace
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # cursor moves to end of old line
+  screen-should-contain [
+    .           run (F4)           .
+    .               ┊abc␣ef        .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊──────────────.
+    .               ┊              .
+  ]
+]
+
+type render-recipe = (recipe (address screen) (address editor) -> number number (address screen) (address editor))
+
+def render-all screen:&:screen, env:&:environment, render-editor:render-recipe -> screen:&:screen, env:&:environment [
+  local-scope
+  load-inputs
+  trace 10, [app], [render all]
+  # top menu
+  trace 11, [app], [render top menu]
+  width:num <- screen-width screen
+  draw-horizontal screen, 0, 0/left, width, 32/space, 0/black, 238/grey
+  button-start:num <- subtract width, 20
+  button-on-screen?:bool <- greater-or-equal button-start, 0
+  assert button-on-screen?, [screen too narrow for menu]
+  screen <- move-cursor screen, 0/row, button-start
+  print screen, [ run (F4) ], 255/white, 161/reddish
+  # dotted line down the middle
+  trace 11, [app], [render divider]
+  divider:num, _ <- divide-with-remainder width, 2
+  height:num <- screen-height screen
+  draw-vertical screen, divider, 1/top, height, 9482/vertical-dotted
+  #
+  screen <- render-recipes screen, env, render-editor
+  screen <- render-sandbox-side screen, env, render-editor
+  <end-render-components>  # no early returns permitted
+  #
+  recipes:&:editor <- get *env, recipes:offset
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  sandbox-in-focus?:bool <- get *env, sandbox-in-focus?:offset
+  screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+]
+
+def render-recipes screen:&:screen, env:&:environment, render-editor:render-recipe -> screen:&:screen, env:&:environment [
+  local-scope
+  load-inputs
+  trace 11, [app], [render recipes]
+  old-top-idx:num <- save-top-idx screen
+  recipes:&:editor <- get *env, recipes:offset
+  # render recipes
+  left:num <- get *recipes, left:offset
+  right:num <- get *recipes, right:offset
+  row:num, column:num, screen <- call render-editor, screen, recipes
+  <end-render-recipe-components>
+  # draw dotted line after recipes
+  draw-horizontal screen, row, left, right, 9480/horizontal-dotted
+  row <- add row, 1
+  clear-screen-from screen, row, left, left, right
+  #
+  assert-no-scroll screen, old-top-idx
+]
+
+# replaced in a later layer
+def render-sandbox-side screen:&:screen, env:&:environment, render-editor:render-recipe -> screen:&:screen, env:&:environment [
+  local-scope
+  load-inputs
+  trace 11, [app], [render sandboxes]
+  old-top-idx:num <- save-top-idx screen
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  left:num <- get *current-sandbox, left:offset
+  right:num <- get *current-sandbox, right:offset
+  row:num, column:num, screen, current-sandbox <- call render-editor, screen, current-sandbox
+  # draw solid line after code (you'll see why in later layers)
+  draw-horizontal screen, row, left, right
+  row <- add row, 1
+  clear-screen-from screen, row, left, left, right
+  #
+  assert-no-scroll screen, old-top-idx
+]
+
+def update-cursor screen:&:screen, recipes:&:editor, current-sandbox:&:editor, sandbox-in-focus?:bool, env:&:environment -> screen:&:screen [
+  local-scope
+  load-inputs
+  <update-cursor-special-cases>
+  {
+    break-if sandbox-in-focus?
+    cursor-row:num <- get *recipes, cursor-row:offset
+    cursor-column:num <- get *recipes, cursor-column:offset
+  }
+  {
+    break-unless sandbox-in-focus?
+    cursor-row:num <- get *current-sandbox, cursor-row:offset
+    cursor-column:num <- get *current-sandbox, cursor-column:offset
+  }
+  screen <- move-cursor screen, cursor-row, cursor-column
+]
+
+# ctrl-n - switch focus
+# todo: test this
+
+after <global-type> [
+  {
+    switch-side?:bool <- equal c, 14/ctrl-n
+    break-unless switch-side?
+    sandbox-in-focus?:bool <- get *env, sandbox-in-focus?:offset
+    sandbox-in-focus? <- not sandbox-in-focus?
+    *env <- put *env, sandbox-in-focus?:offset, sandbox-in-focus?
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+## helpers
+
+def draw-vertical screen:&:screen, col:num, y:num, bottom:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  style:char, style-found?:bool <- next-input
+  {
+    break-if style-found?
+    style <- copy 9474/vertical
+  }
+  color:num, color-found?:bool <- next-input
+  {
+    # default color to white
+    break-if color-found?
+    color <- copy 245/grey
+  }
+  {
+    continue?:bool <- lesser-than y, bottom
+    break-unless continue?
+    screen <- move-cursor screen, y, col
+    print screen, style, color
+    y <- add y, 1
+    loop
+  }
+]
+
+scenario backspace-over-text [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 50/width, 15/height
+  # recipes.mu is empty
+  assume-resources [
+  ]
+  # sandbox editor contains an instruction without storing outputs
+  env:&:environment <- new-programming-environment resources, screen, []
+  # run the code in the editors
+  assume-console [
+    type [a]
+    press backspace
+  ]
+  run [
+    event-loop screen, console, env, resources
+    10:num/raw <- get *screen, cursor-row:offset
+    11:num/raw <- get *screen, cursor-column:offset
+  ]
+  memory-should-contain [
+    10 <- 1
+    11 <- 0
+  ]
+]
diff --git a/archive/2.vm/edit/005-sandbox.mu b/archive/2.vm/edit/005-sandbox.mu
new file mode 100644
index 00000000..96ec804d
--- /dev/null
+++ b/archive/2.vm/edit/005-sandbox.mu
@@ -0,0 +1,1193 @@
+## running code from the editor and creating sandboxes
+#
+# Running code in the sandbox editor prepends its contents to a list of
+# (non-editable) sandboxes below the editor, showing the result and maybe a
+# few other things (later layers).
+#
+# This layer draws the menubar buttons in non-editable sandboxes but they
+# don't do anything yet. Later layers implement each button.
+
+def! main [
+  local-scope
+  open-console
+  clear-screen null/screen  # non-scrolling app
+  env:&:environment <- new-programming-environment null/filesystem, null/screen
+  env <- restore-sandboxes env, null/filesystem
+  render-all null/screen, env, render
+  event-loop null/screen, null/console, env, null/filesystem
+]
+
+container environment [
+  sandbox:&:sandbox  # list of sandboxes, from top to bottom. TODO: switch to &:list:sandbox
+  render-from:num
+  number-of-sandboxes:num
+]
+
+after <programming-environment-initialization> [
+  *result <- put *result, render-from:offset, -1
+]
+
+container sandbox [
+  data:text
+  response:text
+  # coordinates to track clicks
+  # constraint: will be 0 for sandboxes at positions before env.render-from
+  starting-row-on-screen:num
+  code-ending-row-on-screen:num  # past end of code
+  screen:&:screen  # prints in the sandbox go here
+  next-sandbox:&:sandbox
+]
+
+scenario run-and-show-results [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # recipe editor is empty
+  assume-resources [
+  ]
+  # sandbox editor contains an instruction without storing outputs
+  env:&:environment <- new-programming-environment resources, screen, [divide-with-remainder 11, 3]
+  render-all screen, env, render
+  # run the code in the editors
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that screen prints the results
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊divide-with-remainder 11, 3                      .
+    .                                                  ┊3                                                .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  screen-should-contain-in-color 7/white, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   divide-with-remainder 11, 3                      .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+  screen-should-contain-in-color 245/grey, [
+    .                                                                                                    .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+    .                                                  ┊3                                                .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # sandbox menu in reverse video
+  screen-should-contain-in-color 232/black, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   0   edit       copy       to recipe    delete    .
+  ]
+  # run another command
+  assume-console [
+    left-click 1, 80
+    type [add 2, 2]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that screen prints both sandboxes
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊divide-with-remainder 11, 3                      .
+    .                                                  ┊3                                                .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+after <global-keypress> [
+  # F4? load all code and run all sandboxes.
+  {
+    do-run?:bool <- equal k, 65532/F4
+    break-unless do-run?
+    screen <- update-status screen, [running...       ], 245/grey
+    <begin-run-sandboxes-on-F4>
+    error?:bool <- run-sandboxes env, resources, screen
+    # we could just render-all, but we do some work to minimize the number of prints to screen
+    <end-run-sandboxes-on-F4>
+    screen <- render-sandbox-side screen, env, render
+    {
+      break-if error?
+      screen <- update-status screen, [                 ], 245/grey
+    }
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+def run-sandboxes env:&:environment, resources:&:resources, screen:&:screen -> errors-found?:bool, env:&:environment, resources:&:resources, screen:&:screen [
+  local-scope
+  load-inputs
+  errors-found?:bool <- update-recipes env, resources, screen
+  jump-if errors-found?, +return
+  # check contents of right editor (sandbox)
+  <begin-run-sandboxes>
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  {
+    sandbox-contents:text <- editor-contents current-sandbox
+    break-unless sandbox-contents
+    # if contents exist, first save them
+    # run them and turn them into a new sandbox
+    new-sandbox:&:sandbox <- new sandbox:type
+    *new-sandbox <- put *new-sandbox, data:offset, sandbox-contents
+    # push to head of sandbox list
+    dest:&:sandbox <- get *env, sandbox:offset
+    *new-sandbox <- put *new-sandbox, next-sandbox:offset, dest
+    *env <- put *env, sandbox:offset, new-sandbox
+    # update sandbox count
+    sandbox-count:num <- get *env, number-of-sandboxes:offset
+    sandbox-count <- add sandbox-count, 1
+    *env <- put *env, number-of-sandboxes:offset, sandbox-count
+    # save all sandboxes
+    # needs to be before running them, in case we die when running
+    save-sandboxes env, resources
+    # clear sandbox editor
+    init:&:duplex-list:char <- push 167/§, null
+    *current-sandbox <- put *current-sandbox, data:offset, init
+    *current-sandbox <- put *current-sandbox, top-of-screen:offset, init
+  }
+  # run all sandboxes
+  curr:&:sandbox <- get *env, sandbox:offset
+  idx:num <- copy 0
+  {
+    break-unless curr
+    curr <- update-sandbox curr, env, idx
+    curr <- get *curr, next-sandbox:offset
+    idx <- add idx, 1
+    loop
+  }
+  <end-run-sandboxes>
+  +return
+  {
+    break-if resources  # ignore this in tests
+    $system [./snapshot_lesson]
+  }
+]
+
+# load code from disk
+# replaced in a later layer (whereupon errors-found? will actually be set)
+def update-recipes env:&:environment, resources:&:resources, screen:&:screen -> errors-found?:bool, env:&:environment, resources:&:resources, screen:&:screen [
+  local-scope
+  load-inputs
+  recipes:&:editor <- get *env, recipes:offset
+  in:text <- editor-contents recipes
+  resources <- dump resources, [lesson/recipes.mu], in
+  reload in
+  errors-found? <- copy false
+]
+
+# replaced in a later layer
+def update-sandbox sandbox:&:sandbox, env:&:environment, idx:num -> sandbox:&:sandbox, env:&:environment [
+  local-scope
+  load-inputs
+  data:text <- get *sandbox, data:offset
+  response:text, _, fake-screen:&:screen <- run-sandboxed data
+  *sandbox <- put *sandbox, response:offset, response
+  *sandbox <- put *sandbox, screen:offset, fake-screen
+]
+
+def update-status screen:&:screen, msg:text, color:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  screen <- move-cursor screen, 0, 2
+  screen <- print screen, msg, color, 238/grey/background
+]
+
+def save-sandboxes env:&:environment, resources:&:resources -> resources:&:resources [
+  local-scope
+  load-inputs
+  trace 11, [app], [save sandboxes]
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  # first clear previous versions, in case we deleted some sandbox
+  $system [rm lesson/[0-9]* >/dev/null 2>/dev/null]  # some shells can't handle '>&'
+  curr:&:sandbox <- get *env, sandbox:offset
+  idx:num <- copy 0
+  {
+    break-unless curr
+    resources <- save-sandbox resources, curr, idx
+    idx <- add idx, 1
+    curr <- get *curr, next-sandbox:offset
+    loop
+  }
+]
+
+def save-sandbox resources:&:resources, sandbox:&:sandbox, sandbox-index:num -> resources:&:resources [
+  local-scope
+  load-inputs
+  data:text <- get *sandbox, data:offset
+  filename:text <- append [lesson/], sandbox-index
+  resources <- dump resources, filename, data
+  <end-save-sandbox>
+]
+
+def! render-sandbox-side screen:&:screen, env:&:environment, render-editor:render-recipe -> screen:&:screen, env:&:environment [
+  local-scope
+  load-inputs
+  trace 11, [app], [render sandbox side]
+  old-top-idx:num <- save-top-idx screen
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  row:num, column:num <- copy 1, 0
+  left:num <- get *current-sandbox, left:offset
+  right:num <- get *current-sandbox, right:offset
+  # render sandbox editor
+  render-from:num <- get *env, render-from:offset
+  {
+    render-current-sandbox?:bool <- equal render-from, -1
+    break-unless render-current-sandbox?
+    row, column, screen, current-sandbox <- call render-editor, screen, current-sandbox
+  }
+  # render sandboxes
+  draw-horizontal screen, row, left, right
+  sandbox:&:sandbox <- get *env, sandbox:offset
+  row, screen <- render-sandboxes screen, sandbox, left, right, row, render-from
+  clear-rest-of-screen screen, row, left, right
+  #
+  assert-no-scroll screen, old-top-idx
+]
+
+def render-sandboxes screen:&:screen, sandbox:&:sandbox, left:num, right:num, row:num, render-from:num, idx:num -> row:num, screen:&:screen, sandbox:&:sandbox [
+  local-scope
+  load-inputs
+  return-unless sandbox
+  screen-height:num <- screen-height screen
+  hidden?:bool <- lesser-than idx, render-from
+  {
+    break-if hidden?
+    # render sandbox menu
+    row <- add row, 1
+    at-bottom?:bool <- greater-or-equal row, screen-height
+    return-if at-bottom?
+    screen <- move-cursor screen, row, left
+    screen <- render-sandbox-menu screen, idx, left, right
+    # save menu row so we can detect clicks to it later
+    *sandbox <- put *sandbox, starting-row-on-screen:offset, row
+    # render sandbox contents
+    row <- add row, 1
+    screen <- move-cursor screen, row, left
+    sandbox-data:text <- get *sandbox, data:offset
+    row, screen <- render-code screen, sandbox-data, left, right, row
+    *sandbox <- put *sandbox, code-ending-row-on-screen:offset, row
+    # render sandbox warnings, screen or response, in that order
+    sandbox-response:text <- get *sandbox, response:offset
+    <render-sandbox-results>
+    {
+      sandbox-screen:&:screen <- get *sandbox, screen:offset
+      empty-screen?:bool <- fake-screen-is-empty? sandbox-screen
+      break-if empty-screen?
+      row, screen <- render-screen screen, sandbox-screen, left, right, row
+    }
+    {
+      break-unless empty-screen?
+      <render-sandbox-response>
+      row, screen <- render-text screen, sandbox-response, left, right, 245/grey, row
+    }
+    +render-sandbox-end
+    at-bottom?:bool <- greater-or-equal row, screen-height
+    return-if at-bottom?
+    # draw solid line after sandbox
+    draw-horizontal screen, row, left, right
+  }
+  # if hidden, reset row attributes
+  {
+    break-unless hidden?
+    *sandbox <- put *sandbox, starting-row-on-screen:offset, 0
+    *sandbox <- put *sandbox, code-ending-row-on-screen:offset, 0
+    <end-render-sandbox-reset-hidden>
+  }
+  # draw next sandbox
+  next-sandbox:&:sandbox <- get *sandbox, next-sandbox:offset
+  next-idx:num <- add idx, 1
+  row, screen <- render-sandboxes screen, next-sandbox, left, right, row, render-from, next-idx
+]
+
+def render-sandbox-menu screen:&:screen, sandbox-index:num, left:num, right:num -> screen:&:screen [
+  local-scope
+  load-inputs
+  move-cursor-to-column screen, left
+  edit-button-left:num, edit-button-right:num, copy-button-left:num, copy-button-right:num, recipe-button-left:num, recipe-button-right:num, delete-button-left:num <- sandbox-menu-columns left, right
+  print screen, sandbox-index, 232/dark-grey, 245/grey
+  start-buttons:num <- subtract edit-button-left, 1
+  clear-line-until screen, start-buttons, 245/grey
+  print screen, [edit], 232/black, 25/background-blue
+  clear-line-until screen, edit-button-right, 25/background-blue
+  print screen, [copy], 232/black, 58/background-green
+  clear-line-until screen, copy-button-right, 58/background-green
+  print screen, [to recipe], 232/black, 94/background-orange
+  clear-line-until screen, recipe-button-right, 94/background-orange
+  print screen, [delete], 232/black, 52/background-red
+  clear-line-until screen, right, 52/background-red
+]
+
+scenario skip-rendering-sandbox-menu-past-bottom-row [
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 6/height
+  # recipe editor is empty
+  assume-resources [
+    [lesson/0] <- [|add 2, 2|]
+    [lesson/1] <- [|add 1, 1|]
+  ]
+  # create two sandboxes such that the top one just barely fills the screen
+  env:&:environment <- new-programming-environment resources, screen, []
+  env <- restore-sandboxes env, resources
+  run [
+    render-all screen, env, render
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊─────────────────────────────────────────────────.
+  ]
+]
+
+# divide up the menu bar for a sandbox into 3 segments, for edit/copy/delete buttons
+# delete-button-right == right
+# all left/right pairs are inclusive
+def sandbox-menu-columns left:num, right:num -> edit-button-left:num, edit-button-right:num, copy-button-left:num, copy-button-right:num, recipe-button-left:num, recipe-button-right:num, delete-button-left:num [
+  local-scope
+  load-inputs
+  start-buttons:num <- add left, 4/space-for-sandbox-index
+  buttons-space:num <- subtract right, start-buttons
+  button-width:num <- divide-with-remainder buttons-space, 4  # integer division
+  buttons-wide-enough?:bool <- greater-or-equal button-width, 10
+  assert buttons-wide-enough?, [sandbox must be at least 40 or so characters wide]
+  edit-button-left:num <- copy start-buttons
+  copy-button-left:num <- add start-buttons, button-width
+  edit-button-right:num <- subtract copy-button-left, 1
+  recipe-button-left:num <- add copy-button-left, button-width
+  copy-button-right:num <- subtract recipe-button-left, 1
+  delete-button-left:num <- subtract right, button-width, -2  # because 'to recipe' is wider than 'delete'
+  recipe-button-right:num <- subtract delete-button-left, 1
+]
+
+# print a text 's' to 'editor' in 'color' starting at 'row'
+# clear rest of last line, move cursor to next line
+# like 'render-code' but without syntax-based colorization
+def render-text screen:&:screen, s:text, left:num, right:num, color:num, row:num -> row:num, screen:&:screen [
+  local-scope
+  load-inputs
+  return-unless s
+  column:num <- copy left
+  screen <- move-cursor screen, row, column
+  screen-height:num <- screen-height screen
+  i:num <- copy 0
+  len:num <- length *s
+  {
+    +next-character
+    done?:bool <- greater-or-equal i, len
+    break-if done?
+    done? <- greater-or-equal row, screen-height
+    break-if done?
+    c:char <- index *s, i
+    {
+      # newline? move to left rather than 0
+      newline?:bool <- equal c, 10/newline
+      break-unless newline?
+      # clear rest of line in this window
+      {
+        done?:bool <- greater-than column, right
+        break-if done?
+        space:char <- copy 32/space
+        print screen, space
+        column <- add column, 1
+        loop
+      }
+      row <- add row, 1
+      column <- copy left
+      screen <- move-cursor screen, row, column
+      i <- add i, 1
+      loop +next-character
+    }
+    {
+      # at right? wrap.
+      at-right?:bool <- equal column, right
+      break-unless at-right?
+      # print wrap icon
+      wrap-icon:char <- copy 8617/loop-back-to-left
+      print screen, wrap-icon, 245/grey
+      column <- copy left
+      row <- add row, 1
+      screen <- move-cursor screen, row, column
+      # don't increment i
+      loop +next-character
+    }
+    i <- add i, 1
+    print screen, c, color
+    column <- add column, 1
+    loop
+  }
+  was-at-left?:bool <- equal column, left
+  clear-line-until screen, right
+  {
+    break-if was-at-left?
+    row <- add row, 1
+  }
+  move-cursor screen, row, left
+]
+
+scenario render-text-wraps-barely-long-lines [
+  local-scope
+  assume-screen 5/width, 5/height
+  run [
+    render-text screen, [abcde], 0/left, 4/right, 7/white, 1/row
+  ]
+  screen-should-contain [
+    .     .
+    .abcd↩.
+    .e    .
+    .     .
+  ]
+]
+
+# assumes programming environment has no sandboxes; restores them from previous session
+def restore-sandboxes env:&:environment, resources:&:resources -> env:&:environment [
+  local-scope
+  load-inputs
+  # read all scenarios, pushing them to end of a list of scenarios
+  idx:num <- copy 0
+  curr:&:sandbox <- copy null
+  prev:&:sandbox <- copy null
+  {
+    filename:text <- append [lesson/], idx
+    contents:text <- slurp resources, filename
+    break-unless contents  # stop at first error; assuming file didn't exist
+                           # todo: handle empty sandbox
+    # create new sandbox for file
+    curr <- new sandbox:type
+    *curr <- put *curr, data:offset, contents
+    <end-restore-sandbox>
+    {
+      break-if idx
+      *env <- put *env, sandbox:offset, curr
+    }
+    {
+      break-unless idx
+      *prev <- put *prev, next-sandbox:offset, curr
+    }
+    idx <- add idx, 1
+    prev <- copy curr
+    loop
+  }
+  # update sandbox count
+  *env <- put *env, number-of-sandboxes:offset, idx
+]
+
+# print the fake sandbox screen to 'screen' with appropriate delimiters
+# leave cursor at start of next line
+def render-screen screen:&:screen, sandbox-screen:&:screen, left:num, right:num, row:num -> row:num, screen:&:screen [
+  local-scope
+  load-inputs
+  return-unless sandbox-screen
+  # print 'screen:'
+  row <- render-text screen, [screen:], left, right, 245/grey, row
+  screen <- move-cursor screen, row, left
+  # start printing sandbox-screen
+  column:num <- copy left
+  s-width:num <- screen-width sandbox-screen
+  s-height:num <- screen-height sandbox-screen
+  buf:&:@:screen-cell <- get *sandbox-screen, data:offset
+  stop-printing:num <- add left, s-width, 3
+  max-column:num <- min stop-printing, right
+  i:num <- copy 0
+  len:num <- length *buf
+  screen-height:num <- screen-height screen
+  {
+    done?:bool <- greater-or-equal i, len
+    break-if done?
+    done? <- greater-or-equal row, screen-height
+    break-if done?
+    column <- copy left
+    screen <- move-cursor screen, row, column
+    # initial leader for each row: two spaces and a '.'
+    space:char <- copy 32/space
+    print screen, space, 245/grey
+    print screen, space, 245/grey
+    full-stop:char <- copy 46/period
+    print screen, full-stop, 245/grey
+    column <- add left, 3
+    {
+      # print row
+      row-done?:bool <- greater-or-equal column, max-column
+      break-if row-done?
+      curr:screen-cell <- index *buf, i
+      c:char <- get curr, contents:offset
+      color:num <- get curr, color:offset
+      {
+        # damp whites down to grey
+        white?:bool <- equal color, 7/white
+        break-unless white?
+        color <- copy 245/grey
+      }
+      print screen, c, color
+      column <- add column, 1
+      i <- add i, 1
+      loop
+    }
+    # print final '.'
+    print screen, full-stop, 245/grey
+    column <- add column, 1
+    {
+      # clear rest of current line
+      line-done?:bool <- greater-than column, right
+      break-if line-done?
+      print screen, space
+      column <- add column, 1
+      loop
+    }
+    row <- add row, 1
+    loop
+  }
+]
+
+scenario run-updates-results [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 12/height
+  # define a recipe (no indent for the 'add' line below so column numbers are more obvious)
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      ||
+      |recipe foo [|
+      |  local-scope|
+      |  z:num <- add 2, 2|
+      |  reply z|
+      |]|
+    ]
+  ]
+  # sandbox editor contains an instruction without storing outputs
+  env:&:environment <- new-programming-environment resources, screen, [foo]  # contents of sandbox editor
+  render-all screen, env, render
+  $clear-trace
+  # run the code in the editors
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .recipe foo [                                      ┊─────────────────────────────────────────────────.
+    .  local-scope                                     ┊0   edit       copy       to recipe    delete    .
+    .  z:num <- add 2, 2                               ┊foo                                              .
+    .  reply z                                         ┊4                                                .
+    .]                                                 ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # the new sandbox should be saved to disk
+  trace-should-contain [
+    app: save sandboxes
+  ]
+  # no need to update editor
+  trace-should-not-contain [
+    app: render recipes
+  ]
+  # make a change (incrementing one of the args to 'add'), then rerun
+  $clear-trace
+  assume-console [
+    left-click 4, 28  # one past the value of the second arg
+    press backspace
+    type [3]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that screen updates the result on the right
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .recipe foo [                                      ┊─────────────────────────────────────────────────.
+    .  local-scope                                     ┊0   edit       copy       to recipe    delete    .
+    .  z:num <- add 2, 3                               ┊foo                                              .
+    .  reply z                                         ┊5                                                .
+    .]                                                 ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # no need to save sandboxes all over again
+  trace-should-not-contain [
+    app: save sandboxes
+  ]
+]
+
+scenario run-instruction-manages-screen-per-sandbox [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 20/height
+  # empty recipes
+  assume-resources [
+  ]
+  # sandbox editor contains an instruction
+  env:&:environment <- new-programming-environment resources, screen, [print screen, 4]  # contents of sandbox editor
+  render-all screen, env, render
+  # run the code in the editor
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that it prints a little toy screen
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊print screen, 4                                  .
+    .                                                  ┊screen:                                          .
+    .                                                  ┊  .4                             .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+def editor-contents editor:&:editor -> result:text [
+  local-scope
+  load-inputs
+  buf:&:buffer:char <- new-buffer 80
+  curr:&:duplex-list:char <- get *editor, data:offset
+  # skip § sentinel
+  assert curr, [editor without data is illegal; must have at least a sentinel]
+  curr <- next curr
+  return-unless curr, null
+  {
+    break-unless curr
+    c:char <- get *curr, value:offset
+    buf <- append buf, c
+    curr <- next curr
+    loop
+  }
+  result <- buffer-to-array buf
+]
+
+scenario editor-provides-edited-contents [
+  local-scope
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [abc], 0/left, 10/right
+  assume-console [
+    left-click 1, 2
+    type [def]
+  ]
+  run [
+    editor-event-loop screen, console, e
+    s:text <- editor-contents e
+    1:@:char/raw <- copy *s
+  ]
+  memory-should-contain [
+    1:array:character <- [abdefc]
+  ]
+]
+
+# keep the bottom of recipes from scrolling off the screen
+
+scenario scrolling-down-past-bottom-of-recipe-editor [
+  local-scope
+  trace-until 100/app
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  assume-console [
+    press enter
+    press down-arrow
+  ]
+  event-loop screen, console, env, resources
+  # no scroll
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario cursor-down-in-recipe-editor [
+  local-scope
+  trace-until 100/app
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  assume-console [
+    press enter
+    press up-arrow
+    press down-arrow  # while cursor isn't at bottom
+  ]
+  event-loop screen, console, env, resources
+  cursor:char <- copy 9251/␣
+  print screen, cursor
+  # cursor moves back to bottom
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .␣                                                 ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario scrolling-down-past-bottom-of-recipe-editor-2 [
+  local-scope
+  trace-until 100/app
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  assume-console [
+    # add a line
+    press enter
+    # cursor back to top line
+    press up-arrow
+    # try to scroll
+    press page-down  # or ctrl-f
+  ]
+  event-loop screen, console, env, resources
+  # no scroll, and cursor remains at top line
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario scrolling-down-past-bottom-of-recipe-editor-3 [
+  local-scope
+  trace-until 100/app
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [ab
+cd]
+  render-all screen, env, render
+  assume-console [
+    # add a line
+    press enter
+    # switch to sandbox
+    press ctrl-n
+    # move cursor
+    press down-arrow
+  ]
+  event-loop screen, console, env, resources
+  cursor:char <- copy 9251/␣
+  print screen, cursor
+  # no scroll on recipe side, cursor moves on sandbox side
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊ab                                               .
+    .                                                  ┊␣d                                               .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+# scrolling through sandboxes
+
+scenario scrolling-down-past-bottom-of-sandbox-editor [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 2, 2]
+  render-all screen, env, render
+  assume-console [
+    # create a sandbox
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+  ]
+  # switch to sandbox window and hit 'page-down'
+  assume-console [
+    press ctrl-n
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # sandbox editor hidden; first sandbox displayed
+  # cursor moves to first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊␣   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # hit 'page-up'
+  assume-console [
+    press page-up
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # sandbox editor displays again, cursor is in editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊␣                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+  ]
+]
+
+# page-down on sandbox side updates render-from to scroll sandboxes
+after <global-keypress> [
+  {
+    break-unless sandbox-in-focus?
+    page-down?:bool <- equal k, 65518/page-down
+    break-unless page-down?
+    sandbox:&:sandbox <- get *env, sandbox:offset
+    break-unless sandbox
+    # slide down if possible
+    {
+      render-from:num <- get *env, render-from:offset
+      number-of-sandboxes:num <- get *env, number-of-sandboxes:offset
+      max:num <- subtract number-of-sandboxes, 1
+      at-end?:bool <- greater-or-equal render-from, max
+      loop-if at-end?, +next-event  # render nothing
+      render-from <- add render-from, 1
+      *env <- put *env, render-from:offset, render-from
+    }
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# update-cursor takes render-from into account
+after <update-cursor-special-cases> [
+  {
+    break-unless sandbox-in-focus?
+    render-from:num <- get *env, render-from:offset
+    scrolling?:bool <- greater-or-equal render-from, 0
+    break-unless scrolling?
+    cursor-column:num <- get *current-sandbox, left:offset
+    screen <- move-cursor screen, 2/row, cursor-column  # highlighted sandbox will always start at row 2
+    return
+  }
+]
+
+# 'page-up' on sandbox side is like 'page-down': updates render-from when necessary
+after <global-keypress> [
+  {
+    break-unless sandbox-in-focus?
+    page-up?:bool <- equal k, 65519/page-up
+    break-unless page-up?
+    render-from:num <- get *env, render-from:offset
+    at-beginning?:bool <- equal render-from, -1
+    break-if at-beginning?
+    render-from <- subtract render-from, 1
+    *env <- put *env, render-from:offset, render-from
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# sandbox belonging to 'env' whose next-sandbox is 'in'
+# return null if there's no such sandbox, either because 'in' doesn't exist in 'env', or because it's the first sandbox
+def previous-sandbox env:&:environment, in:&:sandbox -> out:&:sandbox [
+  local-scope
+  load-inputs
+  curr:&:sandbox <- get *env, sandbox:offset
+  return-unless curr, null
+  next:&:sandbox <- get *curr, next-sandbox:offset
+  {
+    return-unless next, null
+    found?:bool <- equal next, in
+    break-if found?
+    curr <- copy next
+    next <- get *curr, next-sandbox:offset
+    loop
+  }
+  return curr
+]
+
+scenario scrolling-through-multiple-sandboxes [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  cursor:char <- copy 9251/␣
+  print screen, cursor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊␣                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # hit 'page-down'
+  assume-console [
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # sandbox editor hidden; first sandbox displayed
+  # cursor moves to first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊␣   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # hit 'page-down' again
+  assume-console [
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # just second sandbox displayed
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # hit 'page-down' again
+  assume-console [
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # no change
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # hit 'page-up'
+  assume-console [
+    press page-up
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # back to displaying both sandboxes without editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # hit 'page-up' again
+  assume-console [
+    press page-up
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # back to displaying both sandboxes as well as editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊␣                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # hit 'page-up' again
+  assume-console [
+    press page-up
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # no change
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊␣                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+]
+
+scenario scrolling-manages-sandbox-index-correctly [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create a sandbox
+  assume-console [
+    press ctrl-n
+    type [add 1, 1]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # hit 'page-down' and 'page-up' a couple of times. sandbox index should be stable
+  assume-console [
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # sandbox editor hidden; first sandbox displayed
+  # cursor moves to first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # hit 'page-up' again
+  assume-console [
+    press page-up
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # back to displaying both sandboxes as well as editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # hit 'page-down'
+  assume-console [
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # sandbox editor hidden; first sandbox displayed
+  # cursor moves to first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
diff --git a/archive/2.vm/edit/006-sandbox-copy.mu b/archive/2.vm/edit/006-sandbox-copy.mu
new file mode 100644
index 00000000..6af72f77
--- /dev/null
+++ b/archive/2.vm/edit/006-sandbox-copy.mu
@@ -0,0 +1,395 @@
+## the 'copy' button makes it easy to duplicate a sandbox, and thence to
+## see code operate in multiple situations
+
+scenario copy-a-sandbox-to-editor [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 1, 1]  # contents of sandbox editor
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click at left edge of 'copy' button
+  assume-console [
+    left-click 3, 69
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # it copies into editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊add 1, 1                                         .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [0]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊0add 1, 1                                        .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario copy-a-sandbox-to-editor-2 [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 1, 1]  # contents of sandbox editor
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click at right edge of 'copy' button (just before 'delete')
+  assume-console [
+    left-click 3, 76
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # it copies into editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊add 1, 1                                         .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [0]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊0add 1, 1                                        .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+after <global-touch> [
+  # support 'copy' button
+  {
+    copy?:bool <- should-attempt-copy? click-row, click-column, env
+    break-unless copy?
+    copy?, env <- try-copy-sandbox click-row, env
+    break-unless copy?
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# some preconditions for attempting to copy a sandbox
+def should-attempt-copy? click-row:num, click-column:num, env:&:environment -> result:bool [
+  local-scope
+  load-inputs
+  # are we below the sandbox editor?
+  click-sandbox-area?:bool <- click-on-sandbox-area? click-row, click-column, env
+  return-unless click-sandbox-area?, false
+  # narrower, is the click in the columns spanning the 'copy' button?
+  first-sandbox:&:editor <- get *env, current-sandbox:offset
+  assert first-sandbox, [!!]
+  sandbox-left-margin:num <- get *first-sandbox, left:offset
+  sandbox-right-margin:num <- get *first-sandbox, right:offset
+  _, _, copy-button-left:num, copy-button-right:num <- sandbox-menu-columns sandbox-left-margin, sandbox-right-margin
+  copy-button-vertical-area?:bool <- within-range? click-column, copy-button-left, copy-button-right
+  return-unless copy-button-vertical-area?, false
+  # finally, is sandbox editor empty?
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  result <- empty-editor? current-sandbox
+]
+
+def try-copy-sandbox click-row:num, env:&:environment -> clicked-on-copy-button?:bool, env:&:environment [
+  local-scope
+  load-inputs
+  # identify the sandbox to copy, if the click was actually on the 'copy' button
+  sandbox:&:sandbox <- find-sandbox env, click-row
+  return-unless sandbox, false
+  clicked-on-copy-button? <- copy true
+  text:text <- get *sandbox, data:offset
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  current-sandbox <- insert-text current-sandbox, text
+  # reset scroll
+  *env <- put *env, render-from:offset, -1
+  # position cursor in sandbox editor
+  *env <- put *env, sandbox-in-focus?:offset, true
+]
+
+def find-sandbox env:&:environment, click-row:num -> result:&:sandbox [
+  local-scope
+  load-inputs
+  curr-sandbox:&:sandbox <- get *env, sandbox:offset
+  {
+    break-unless curr-sandbox
+    start:num <- get *curr-sandbox, starting-row-on-screen:offset
+    found?:bool <- equal click-row, start
+    return-if found?, curr-sandbox
+    curr-sandbox <- get *curr-sandbox, next-sandbox:offset
+    loop
+  }
+  return null/not-found
+]
+
+def click-on-sandbox-area? click-row:num, click-column:num, env:&:environment -> result:bool [
+  local-scope
+  load-inputs
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  sandbox-left-margin:num <- get *current-sandbox, left:offset
+  on-sandbox-side?:bool <- greater-or-equal click-column, sandbox-left-margin
+  return-unless on-sandbox-side?, false
+  first-sandbox:&:sandbox <- get *env, sandbox:offset
+  return-unless first-sandbox, false
+  first-sandbox-begins:num <- get *first-sandbox, starting-row-on-screen:offset
+  result <- greater-or-equal click-row, first-sandbox-begins
+]
+
+def empty-editor? editor:&:editor -> result:bool [
+  local-scope
+  load-inputs
+  head:&:duplex-list:char <- get *editor, data:offset
+  first:&:duplex-list:char <- next head
+  result <- not first
+]
+
+def within-range? x:num, low:num, high:num -> result:bool [
+  local-scope
+  load-inputs
+  not-too-far-left?:bool <- greater-or-equal x, low
+  not-too-far-right?:bool <- lesser-or-equal x, high
+  result <- and not-too-far-left? not-too-far-right?
+]
+
+scenario copy-fails-if-sandbox-editor-not-empty [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 1, 1]  # contents of sandbox editor
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # type something into the sandbox editor, then click on the 'copy' button
+  assume-console [
+    left-click 2, 70  # put cursor in sandbox editor
+    type [0]  # type something
+    left-click 3, 70  # click 'copy' button
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # copy doesn't happen
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊0                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊01                                               .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+## the 'to recipe' button makes it easy to create a function out of a sandbox
+
+scenario copy-a-sandbox-to-recipe-side [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 1, 1]  # contents of sandbox editor
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click at left edge of 'copy' button
+  assume-console [
+    left-click 3, 78
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # it copies into recipe side
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .add 1, 1                                          ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be at the top left of the recipe side
+  assume-console [
+    type [0]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .0add 1, 1                                         ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+after <global-touch> [
+  # support 'copy to recipe' button
+  {
+    copy?:bool <- should-copy-to-recipe? click-row, click-column, env
+    break-unless copy?
+    modified?:bool <- prepend-sandbox-into-recipe-side click-row, env
+    break-unless modified?
+    *env <- put *env, sandbox-in-focus?:offset, false
+    screen <- render-recipes screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# some preconditions for attempting to copy a sandbox into the recipe side
+def should-copy-to-recipe? click-row:num, click-column:num, env:&:environment -> result:bool [
+  local-scope
+  load-inputs
+  # are we below the sandbox editor?
+  click-sandbox-area?:bool <- click-on-sandbox-area? click-row, click-column, env
+  return-unless click-sandbox-area?, false
+  # narrower, is the click in the columns spanning the 'copy' button?
+  first-sandbox:&:editor <- get *env, current-sandbox:offset
+  assert first-sandbox, [!!]
+  sandbox-left-margin:num <- get *first-sandbox, left:offset
+  sandbox-right-margin:num <- get *first-sandbox, right:offset
+  _, _, _, _, recipe-button-left:num, recipe-button-right:num <- sandbox-menu-columns sandbox-left-margin, sandbox-right-margin
+  result <- within-range? click-column, recipe-button-left, recipe-button-right
+]
+
+def prepend-sandbox-into-recipe-side click-row:num, env:&:environment -> clicked-on-copy-to-recipe-button?:bool, env:&:environment [
+  local-scope
+  load-inputs
+  sandbox:&:sandbox <- find-sandbox env, click-row
+  return-unless sandbox, false
+  recipe-editor:&:editor <- get *env, recipes:offset
+  recipe-data:&:duplex-list:char <- get *recipe-editor, data:offset
+  # make the newly inserted code easy to delineate
+  newline:char <- copy 10
+  insert newline, recipe-data
+  insert newline, recipe-data
+  # insert code from the selected sandbox
+  sandbox-data:text <- get *sandbox, data:offset
+  insert recipe-data, sandbox-data
+  # reset cursor
+  *recipe-editor <- put *recipe-editor, top-of-screen:offset, recipe-data
+  *recipe-editor <- put *recipe-editor, before-cursor:offset, recipe-data
+  *recipe-editor <- put *recipe-editor, cursor-row:offset, 1
+  *recipe-editor <- put *recipe-editor, cursor-column:offset, 0
+  return true
+]
diff --git a/archive/2.vm/edit/007-sandbox-delete.mu b/archive/2.vm/edit/007-sandbox-delete.mu
new file mode 100644
index 00000000..5e7989fb
--- /dev/null
+++ b/archive/2.vm/edit/007-sandbox-delete.mu
@@ -0,0 +1,342 @@
+## deleting sandboxes
+
+scenario deleting-sandboxes [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # run a few commands
+  assume-console [
+    left-click 1, 75
+    type [divide-with-remainder 11, 3]
+    press F4
+    type [add 2, 2]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊divide-with-remainder 11, 3                      .
+    .                                                  ┊3                                                .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # delete second sandbox by clicking on left edge of 'delete' button
+  assume-console [
+    left-click 7, 90
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # delete first sandbox by clicking at right edge of 'delete' button
+  assume-console [
+    left-click 3, 99
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+after <global-touch> [
+  # support 'delete' button
+  {
+    delete?:bool <- should-attempt-delete? click-row, click-column, env
+    break-unless delete?
+    delete?, env <- try-delete-sandbox click-row, env
+    break-unless delete?
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# some preconditions for attempting to delete a sandbox
+def should-attempt-delete? click-row:num, click-column:num, env:&:environment -> result:bool [
+  local-scope
+  load-inputs
+  # are we below the sandbox editor?
+  click-sandbox-area?:bool <- click-on-sandbox-area? click-row, click-column, env
+  return-unless click-sandbox-area?, false
+  # narrower, is the click in the columns spanning the 'copy' button?
+  first-sandbox:&:editor <- get *env, current-sandbox:offset
+  assert first-sandbox, [!!]
+  sandbox-left-margin:num <- get *first-sandbox, left:offset
+  sandbox-right-margin:num <- get *first-sandbox, right:offset
+  _, _, _, _, _, _, delete-button-left:num <- sandbox-menu-columns sandbox-left-margin, sandbox-right-margin
+  result <- within-range? click-column, delete-button-left, sandbox-right-margin
+]
+
+def try-delete-sandbox click-row:num, env:&:environment -> clicked-on-delete-button?:bool, env:&:environment [
+  local-scope
+  load-inputs
+  # identify the sandbox to delete, if the click was actually on the 'delete' button
+  sandbox:&:sandbox <- find-sandbox env, click-row
+  return-unless sandbox, false
+  clicked-on-delete-button? <- copy true
+  env <- delete-sandbox env, sandbox
+]
+
+def delete-sandbox env:&:environment, sandbox:&:sandbox -> env:&:environment [
+  local-scope
+  load-inputs
+  curr-sandbox:&:sandbox <- get *env, sandbox:offset
+  first-sandbox?:bool <- equal curr-sandbox, sandbox
+  {
+    # first sandbox? pop
+    break-unless first-sandbox?
+    next-sandbox:&:sandbox <- get *curr-sandbox, next-sandbox:offset
+    *env <- put *env, sandbox:offset, next-sandbox
+  }
+  {
+    # not first sandbox?
+    break-if first-sandbox?
+    prev-sandbox:&:sandbox <- copy curr-sandbox
+    curr-sandbox <- get *curr-sandbox, next-sandbox:offset
+    {
+      assert curr-sandbox, [sandbox not found! something is wrong.]
+      found?:bool <- equal curr-sandbox, sandbox
+      break-if found?
+      prev-sandbox <- copy curr-sandbox
+      curr-sandbox <- get *curr-sandbox, next-sandbox:offset
+      loop
+    }
+    # snip sandbox out of its list
+    next-sandbox:&:sandbox <- get *curr-sandbox, next-sandbox:offset
+    *prev-sandbox <- put *prev-sandbox, next-sandbox:offset, next-sandbox
+  }
+  # update sandbox count
+  sandbox-count:num <- get *env, number-of-sandboxes:offset
+  sandbox-count <- subtract sandbox-count, 1
+  *env <- put *env, number-of-sandboxes:offset, sandbox-count
+  # reset scroll if deleted sandbox was last
+  {
+    break-if next-sandbox
+    render-from:num <- get *env, render-from:offset
+    reset-scroll?:bool <- equal render-from, sandbox-count
+    break-unless reset-scroll?
+    *env <- put *env, render-from:offset, -1
+  }
+]
+
+scenario deleting-sandbox-after-scroll [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes and scroll to second
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+    press page-down
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+  ]
+  # delete the second sandbox
+  assume-console [
+    left-click 6, 99
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # second sandbox shows in editor; scroll resets to display first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario deleting-top-sandbox-after-scroll [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes and scroll to second
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+    press page-down
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+  ]
+  # delete the second sandbox
+  assume-console [
+    left-click 2, 99
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # second sandbox shows in editor; scroll resets to display first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario deleting-final-sandbox-after-scroll [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes and scroll to second
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+    press page-down
+    press page-down
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # delete the second sandbox
+  assume-console [
+    left-click 2, 99
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # implicitly scroll up to first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario deleting-updates-sandbox-count [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # delete the second sandbox, then try to scroll down twice
+  assume-console [
+    left-click 3, 99
+    press page-down
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # shouldn't go past last sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
diff --git a/archive/2.vm/edit/008-sandbox-edit.mu b/archive/2.vm/edit/008-sandbox-edit.mu
new file mode 100644
index 00000000..57cdbc0d
--- /dev/null
+++ b/archive/2.vm/edit/008-sandbox-edit.mu
@@ -0,0 +1,325 @@
+## editing sandboxes after they've been created
+
+scenario clicking-on-sandbox-edit-button-moves-it-to-editor [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 2, 2]
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click at left edge of 'edit' button
+  assume-console [
+    left-click 3, 55
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # it pops back into editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊add 2, 2                                         .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [0]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊0add 2, 2                                        .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario clicking-on-sandbox-edit-button-moves-it-to-editor-2 [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # empty recipes
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [add 2, 2]
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click at right edge of 'edit' button (just before 'copy')
+  assume-console [
+    left-click 3, 65
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # it pops back into editor
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊add 2, 2                                         .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [0]
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊0add 2, 2                                        .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+after <global-touch> [
+  # support 'edit' button
+  {
+    edit?:bool <- should-attempt-edit? click-row, click-column, env
+    break-unless edit?
+    edit?, env <- try-edit-sandbox click-row, env
+    break-unless edit?
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+# some preconditions for attempting to edit a sandbox
+def should-attempt-edit? click-row:num, click-column:num, env:&:environment -> result:bool [
+  local-scope
+  load-inputs
+  # are we below the sandbox editor?
+  click-sandbox-area?:bool <- click-on-sandbox-area? click-row, click-column, env
+  return-unless click-sandbox-area?, false
+  # narrower, is the click in the columns spanning the 'edit' button?
+  first-sandbox:&:editor <- get *env, current-sandbox:offset
+  assert first-sandbox, [!!]
+  sandbox-left-margin:num <- get *first-sandbox, left:offset
+  sandbox-right-margin:num <- get *first-sandbox, right:offset
+  edit-button-left:num, edit-button-right:num, _ <- sandbox-menu-columns sandbox-left-margin, sandbox-right-margin
+  edit-button-vertical-area?:bool <- within-range? click-column, edit-button-left, edit-button-right
+  return-unless edit-button-vertical-area?, false
+  # finally, is sandbox editor empty?
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  result <- empty-editor? current-sandbox
+]
+
+def try-edit-sandbox click-row:num, env:&:environment -> clicked-on-edit-button?:bool, env:&:environment [
+  local-scope
+  load-inputs
+  # identify the sandbox to edit, if the click was actually on the 'edit' button
+  sandbox:&:sandbox <- find-sandbox env, click-row
+  return-unless sandbox, false
+  clicked-on-edit-button? <- copy true
+  # 'edit' button = 'copy' button + 'delete' button
+  text:text <- get *sandbox, data:offset
+  current-sandbox:&:editor <- get *env, current-sandbox:offset
+  current-sandbox <- insert-text current-sandbox, text
+  env <- delete-sandbox env, sandbox
+  # reset scroll
+  *env <- put *env, render-from:offset, -1
+  # position cursor in sandbox editor
+  *env <- put *env, sandbox-in-focus?:offset, true
+]
+
+scenario sandbox-with-print-can-be-edited [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 20/height
+  # left editor is empty
+  assume-resources [
+  ]
+  # right editor contains a print instruction
+  env:&:environment <- new-programming-environment resources, screen, [print screen, 4]
+  render-all screen, env, render
+  # run the sandbox
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊print screen, 4                                  .
+    .                                                  ┊screen:                                          .
+    .                                                  ┊  .4                             .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊  .                              .               .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # edit the sandbox
+  assume-console [
+    left-click 3, 65
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊print screen, 4                                  .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario editing-sandbox-after-scrolling-resets-scroll [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes and scroll to second
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+    press page-down
+    press page-down
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # edit the second sandbox
+  assume-console [
+    left-click 2, 55
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # second sandbox shows in editor; scroll resets to display first sandbox
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊add 2, 2                                         .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario editing-sandbox-updates-sandbox-count [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # initialize environment
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  # create 2 sandboxes
+  assume-console [
+    press ctrl-n
+    type [add 2, 2]
+    press F4
+    type [add 1, 1]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # edit the second sandbox, then resave
+  assume-console [
+    left-click 3, 60
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # no change in contents
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 1, 1                                         .
+    .                                                  ┊2                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+  ]
+  # now try to scroll past end
+  assume-console [
+    press page-down
+    press page-down
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # screen should show just final sandbox with the right index (1)
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊1   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
diff --git a/archive/2.vm/edit/009-sandbox-test.mu b/archive/2.vm/edit/009-sandbox-test.mu
new file mode 100644
index 00000000..52c1e909
--- /dev/null
+++ b/archive/2.vm/edit/009-sandbox-test.mu
@@ -0,0 +1,231 @@
+## clicking on sandbox results to 'fix' them and turn sandboxes into tests
+
+scenario sandbox-click-on-result-toggles-color-to-green [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 20/height
+  # basic recipe
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  reply 4|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  reply 4                                         ┊─────────────────────────────────────────────────.
+    .]                                                 ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊foo                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click on the '4' in the result
+  $clear-trace
+  assume-console [
+    left-click 5, 51
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # color toggles to green
+  screen-should-contain-in-color 2/green, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   4                                                .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+  # don't render entire sandbox side
+  check-trace-count-for-label-lesser-than 250, [print-character]  # say 5 sandbox lines
+  # cursor should remain unmoved
+  run [
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .␣ecipe foo [                                      ┊                                                 .
+    .  reply 4                                         ┊─────────────────────────────────────────────────.
+    .]                                                 ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊foo                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # now change the result
+  # then rerun
+  assume-console [
+    left-click 2, 11  # cursor to end of line
+    press backspace
+    type [3]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # result turns red
+  screen-should-contain-in-color 1/red, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   3                                                .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+]
+
+# this requires tracking a couple more things
+container sandbox [
+  response-starting-row-on-screen:num
+  expected-response:text
+]
+
+# include expected response when saving or restoring a sandbox
+before <end-save-sandbox> [
+  {
+    expected-response:text <- get *sandbox, expected-response:offset
+    break-unless expected-response
+    filename <- append filename, [.out]
+    resources <- dump resources, filename, expected-response
+  }
+]
+
+before <end-restore-sandbox> [
+  {
+    filename <- append filename, [.out]
+    contents <- slurp resources, filename
+    break-unless contents
+    *curr <- put *curr, expected-response:offset, contents
+  }
+]
+
+# clicks on sandbox responses save it as 'expected'
+after <global-touch> [
+  # check if it's inside the output of any sandbox
+  {
+    sandbox-left-margin:num <- get *current-sandbox, left:offset
+    click-column:num <- get t, column:offset
+    on-sandbox-side?:bool <- greater-or-equal click-column, sandbox-left-margin
+    break-unless on-sandbox-side?
+    first-sandbox:&:sandbox <- get *env, sandbox:offset
+    break-unless first-sandbox
+    first-sandbox-begins:num <- get *first-sandbox, starting-row-on-screen:offset
+    click-row:num <- get t, row:offset
+    below-sandbox-editor?:bool <- greater-or-equal click-row, first-sandbox-begins
+    break-unless below-sandbox-editor?
+    # identify the sandbox whose output is being clicked on
+    sandbox:&:sandbox, sandbox-index:num <- find-click-in-sandbox-output env, click-row
+    break-unless sandbox
+    # update it
+    sandbox <- toggle-expected-response sandbox
+    # minimal update to disk
+    save-sandbox resources, sandbox, sandbox-index
+    # minimal update to screen
+    sandbox-right-margin:num <- get *current-sandbox, right:offset
+    row:num <- render-sandbox-response screen, sandbox, sandbox-left-margin, sandbox-right-margin
+    {
+      height:num <- screen-height screen
+      at-bottom?:bool <- greater-or-equal row, height
+      break-if at-bottom?
+      draw-horizontal screen, row, sandbox-left-margin, sandbox-right-margin
+    }
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+def find-click-in-sandbox-output env:&:environment, click-row:num -> sandbox:&:sandbox, sandbox-index:num [
+  local-scope
+  load-inputs
+  # assert click-row >= sandbox.starting-row-on-screen
+  sandbox:&:sandbox <- get *env, sandbox:offset
+  start:num <- get *sandbox, starting-row-on-screen:offset
+  clicked-on-sandboxes?:bool <- greater-or-equal click-row, start
+  assert clicked-on-sandboxes?, [extract-sandbox called on click to sandbox editor]
+  # while click-row < sandbox.next-sandbox.starting-row-on-screen
+  sandbox-index <- copy 0
+  {
+    next-sandbox:&:sandbox <- get *sandbox, next-sandbox:offset
+    break-unless next-sandbox
+    next-start:num <- get *next-sandbox, starting-row-on-screen:offset
+    found?:bool <- lesser-than click-row, next-start
+    break-if found?
+    sandbox <- copy next-sandbox
+    sandbox-index <- add sandbox-index, 1
+    loop
+  }
+  # return sandbox if click is in its output region
+  response-starting-row:num <- get *sandbox, response-starting-row-on-screen:offset
+  return-unless response-starting-row, null/no-click-in-sandbox-output, 0/sandbox-index
+  click-in-response?:bool <- greater-or-equal click-row, response-starting-row
+  return-unless click-in-response?, null/no-click-in-sandbox-output, 0/sandbox-index
+  return sandbox, sandbox-index
+]
+
+def toggle-expected-response sandbox:&:sandbox -> sandbox:&:sandbox [
+  local-scope
+  load-inputs
+  expected-response:text <- get *sandbox, expected-response:offset
+  {
+    # if expected-response is set, reset
+    break-unless expected-response
+    *sandbox <- put *sandbox, expected-response:offset, null
+  }
+  {
+    # if not, set expected response to the current response
+    break-if expected-response
+    response:text <- get *sandbox, response:offset
+    *sandbox <- put *sandbox, expected-response:offset, response
+  }
+]
+
+# when rendering a sandbox, color it in red/green if expected response exists
+after <render-sandbox-response> [
+  {
+    break-unless sandbox-response
+    *sandbox <- put *sandbox, response-starting-row-on-screen:offset, row
+    row <- render-sandbox-response screen, sandbox, left, right
+    jump +render-sandbox-end
+  }
+]
+
+def render-sandbox-response screen:&:screen, sandbox:&:sandbox, left:num, right:num -> row:num, screen:&:screen [
+  local-scope
+  load-inputs
+  sandbox-response:text <- get *sandbox, response:offset
+  expected-response:text <- get *sandbox, expected-response:offset
+  row:num <- get *sandbox response-starting-row-on-screen:offset
+  {
+    break-if expected-response
+    row <- render-text screen, sandbox-response, left, right, 245/grey, row
+    return
+  }
+  response-is-expected?:bool <- equal expected-response, sandbox-response
+  {
+    break-if response-is-expected?
+    row <- render-text screen, sandbox-response, left, right, 1/red, row
+  }
+  {
+    break-unless response-is-expected?:bool
+    row <- render-text screen, sandbox-response, left, right, 2/green, row
+  }
+]
+
+before <end-render-sandbox-reset-hidden> [
+  *sandbox <- put *sandbox, response-starting-row-on-screen:offset, 0
+]
diff --git a/archive/2.vm/edit/010-sandbox-trace.mu b/archive/2.vm/edit/010-sandbox-trace.mu
new file mode 100644
index 00000000..23b88833
--- /dev/null
+++ b/archive/2.vm/edit/010-sandbox-trace.mu
@@ -0,0 +1,253 @@
+## clicking on the code typed into a sandbox toggles its trace
+
+scenario sandbox-click-on-code-toggles-app-trace [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # basic recipe
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  stash [abc]|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  stash [abc]                                     ┊─────────────────────────────────────────────────.
+    .]                                                 ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊foo                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click on the code in the sandbox
+  assume-console [
+    left-click 4, 51
+  ]
+  run [
+    event-loop screen, console, env, resources
+    cursor:char <- copy 9251/␣
+    print screen, cursor
+  ]
+  # trace now printed and cursor shouldn't have budged
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .␣ecipe foo [                                      ┊                                                 .
+    .  stash [abc]                                     ┊─────────────────────────────────────────────────.
+    .]                                                 ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊foo                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊abc                                              .
+  ]
+  screen-should-contain-in-color 245/grey, [
+    .                                                                                                    .
+    .                                                  ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊abc                                              .
+  ]
+  # click again on the same region
+  assume-console [
+    left-click 4, 55
+  ]
+  run [
+    event-loop screen, console, env, resources
+    print screen, cursor
+  ]
+  # trace hidden again
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .␣ecipe foo [                                      ┊                                                 .
+    .  stash [abc]                                     ┊─────────────────────────────────────────────────.
+    .]                                                 ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊foo                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario sandbox-shows-app-trace-and-result [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # basic recipe
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  stash [abc]|
+      |  reply 4|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  # run it
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  stash [abc]                                     ┊─────────────────────────────────────────────────.
+    .  reply 4                                         ┊0   edit       copy       to recipe    delete    .
+    .]                                                 ┊foo                                              .
+    .                                                  ┊4                                                .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  # click on the code in the sandbox
+  assume-console [
+    left-click 4, 51
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # trace now printed above result
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  stash [abc]                                     ┊─────────────────────────────────────────────────.
+    .  reply 4                                         ┊0   edit       copy       to recipe    delete    .
+    .]                                                 ┊foo                                              .
+    .                                                  ┊abc                                              .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊8 instructions run                               .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario clicking-on-app-trace-does-nothing [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [stash 123456789]
+  render-all screen, env, render
+  # create and expand the trace
+  assume-console [
+    press F4
+    left-click 4, 51
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊stash 123456789                                  .
+    .                                                  ┊123456789                                        .
+  ]
+  # click on the stash under the edit-button region (or any of the other buttons, really)
+  assume-console [
+    left-click 5, 57
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # no change; doesn't die
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊stash 123456789                                  .
+    .                                                  ┊123456789                                        .
+  ]
+]
+
+container sandbox [
+  trace:text
+  display-trace?:bool
+]
+
+# replaced in a later layer
+def! update-sandbox sandbox:&:sandbox, env:&:environment, idx:num -> sandbox:&:sandbox, env:&:environment [
+  local-scope
+  load-inputs
+  data:text <- get *sandbox, data:offset
+  response:text, _, fake-screen:&:screen, trace:text <- run-sandboxed data
+  *sandbox <- put *sandbox, response:offset, response
+  *sandbox <- put *sandbox, screen:offset, fake-screen
+  *sandbox <- put *sandbox, trace:offset, trace
+]
+
+# clicks on sandbox code toggle its display-trace? flag
+after <global-touch> [
+  # check if it's inside the code of any sandbox
+  {
+    sandbox-left-margin:num <- get *current-sandbox, left:offset
+    click-column:num <- get t, column:offset
+    on-sandbox-side?:bool <- greater-or-equal click-column, sandbox-left-margin
+    break-unless on-sandbox-side?
+    first-sandbox:&:sandbox <- get *env, sandbox:offset
+    break-unless first-sandbox
+    first-sandbox-begins:num <- get *first-sandbox, starting-row-on-screen:offset
+    click-row:num <- get t, row:offset
+    below-sandbox-editor?:bool <- greater-or-equal click-row, first-sandbox-begins
+    break-unless below-sandbox-editor?
+    # identify the sandbox whose code is being clicked on
+    sandbox:&:sandbox <- find-click-in-sandbox-code env, click-row
+    break-unless sandbox
+    # toggle its display-trace? property
+    x:bool <- get *sandbox, display-trace?:offset
+    x <- not x
+    *sandbox <- put *sandbox, display-trace?:offset, x
+    screen <- render-sandbox-side screen, env, render
+    screen <- update-cursor screen, recipes, current-sandbox, sandbox-in-focus?, env
+    loop +next-event
+  }
+]
+
+def find-click-in-sandbox-code env:&:environment, click-row:num -> sandbox:&:sandbox [
+  local-scope
+  load-inputs
+  # assert click-row >= sandbox.starting-row-on-screen
+  sandbox <- get *env, sandbox:offset
+  start:num <- get *sandbox, starting-row-on-screen:offset
+  clicked-on-sandboxes?:bool <- greater-or-equal click-row, start
+  assert clicked-on-sandboxes?, [extract-sandbox called on click to sandbox editor]
+  # while click-row < sandbox.next-sandbox.starting-row-on-screen
+  {
+    next-sandbox:&:sandbox <- get *sandbox, next-sandbox:offset
+    break-unless next-sandbox
+    next-start:num <- get *next-sandbox, starting-row-on-screen:offset
+    found?:bool <- lesser-than click-row, next-start
+    break-if found?
+    sandbox <- copy next-sandbox
+    loop
+  }
+  # return sandbox if click is in its code region
+  code-ending-row:num <- get *sandbox, code-ending-row-on-screen:offset
+  click-above-response?:bool <- lesser-than click-row, code-ending-row
+  start:num <- get *sandbox, starting-row-on-screen:offset
+  click-below-menu?:bool <- greater-than click-row, start
+  click-on-sandbox-code?:bool <- and click-above-response?, click-below-menu?
+  {
+    break-if click-on-sandbox-code?
+    return null/no-click-in-sandbox-output
+  }
+  return sandbox
+]
+
+# when rendering a sandbox, dump its trace before response/warning if display-trace? property is set
+after <render-sandbox-results> [
+  {
+    display-trace?:bool <- get *sandbox, display-trace?:offset
+    break-unless display-trace?
+    sandbox-trace:text <- get *sandbox, trace:offset
+    break-unless sandbox-trace  # nothing to print; move on
+    row, screen <- render-text screen, sandbox-trace, left, right, 245/grey, row
+  }
+  <render-sandbox-trace-done>
+]
diff --git a/archive/2.vm/edit/011-errors.mu b/archive/2.vm/edit/011-errors.mu
new file mode 100644
index 00000000..47258815
--- /dev/null
+++ b/archive/2.vm/edit/011-errors.mu
@@ -0,0 +1,886 @@
+## handling malformed programs
+
+container environment [
+  recipe-errors:text
+]
+
+# copy code from recipe editor, persist to disk, load, save any errors
+def! update-recipes env:&:environment, resources:&:resources, screen:&:screen -> errors-found?:bool, env:&:environment, resources:&:resources, screen:&:screen [
+  local-scope
+  load-inputs
+  recipes:&:editor <- get *env, recipes:offset
+  in:text <- editor-contents recipes
+  resources <- dump resources, [lesson/recipes.mu], in
+  recipe-errors:text <- reload in
+  *env <- put *env, recipe-errors:offset, recipe-errors
+  # if recipe editor has errors, stop
+  {
+    break-unless recipe-errors
+    update-status screen, [errors found     ], 1/red
+    errors-found? <- copy true
+    return
+  }
+  errors-found? <- copy false
+]
+
+after <begin-run-sandboxes-on-F4> [
+  old-recipe-errors:text <- get *env, recipe-errors:offset
+]
+before <end-run-sandboxes-on-F4> [
+  # if there were recipe errors before, check if we can clear them
+  {
+    break-unless old-recipe-errors
+    screen <- render-recipes screen, env, render
+  }
+  render-recipe-errors env, screen
+]
+
+before <end-render-recipe-components> [
+  row <- render-recipe-errors env, screen
+]
+
+def render-recipe-errors env:&:environment, screen:&:screen -> row:num, screen:&:screen [
+  local-scope
+  load-inputs
+  recipe-errors:text <- get *env, recipe-errors:offset
+  recipes:&:editor <- get *env, recipes:offset
+  row:num <- get *recipes, bottom:offset
+  row <- add row, 1
+  return-unless recipe-errors
+  left:num <- get *recipes, left:offset
+  right:num <- get *recipes, right:offset
+  row, screen <- render-text screen, recipe-errors, left, right, 1/red, row
+  # draw dotted line after recipes
+  draw-horizontal screen, row, left, right, 9480/horizontal-dotted
+  row <- add row, 1
+  clear-screen-from screen, row, left, left, right
+]
+
+container environment [
+  error-index:num  # index of first sandbox with an error (or -1 if none)
+]
+
+after <programming-environment-initialization> [
+  *result <- put *result, error-index:offset, -1
+]
+
+after <begin-run-sandboxes> [
+  *env <- put *env, error-index:offset, -1
+]
+
+before <end-run-sandboxes> [
+  {
+    error-index:num <- get *env, error-index:offset
+    sandboxes-completed-successfully?:bool <- equal error-index, -1
+    break-if sandboxes-completed-successfully?
+    errors-found? <- copy true
+  }
+]
+
+before <end-run-sandboxes-on-F4> [
+  {
+    break-unless error?
+    recipe-errors:text <- get *env, recipe-errors:offset
+    break-if recipe-errors
+    error-index:num <- get *env, error-index:offset
+    sandboxes-completed-successfully?:bool <- equal error-index, -1
+    break-if sandboxes-completed-successfully?
+    error-index-text:text <- to-text error-index
+    status:text <- interpolate [errors found (_)    ], error-index-text
+    update-status screen, status, 1/red
+  }
+]
+
+container sandbox [
+  errors:text
+]
+
+def! update-sandbox sandbox:&:sandbox, env:&:environment, idx:num -> sandbox:&:sandbox, env:&:environment [
+  local-scope
+  load-inputs
+  data:text <- get *sandbox, data:offset
+  response:text, errors:text, fake-screen:&:screen, trace:text, completed?:bool <- run-sandboxed data
+  *sandbox <- put *sandbox, response:offset, response
+  *sandbox <- put *sandbox, errors:offset, errors
+  *sandbox <- put *sandbox, screen:offset, fake-screen
+  *sandbox <- put *sandbox, trace:offset, trace
+  {
+    break-if errors
+    break-if completed?
+    errors <- new [took too long!
+]
+    *sandbox <- put *sandbox, errors:offset, errors
+  }
+  {
+    break-unless errors
+    error-index:num <- get *env, error-index:offset
+    error-not-set?:bool <- equal error-index, -1
+    break-unless error-not-set?
+    *env <- put *env, error-index:offset, idx
+  }
+]
+
+# make sure we render any trace
+after <render-sandbox-trace-done> [
+  {
+    sandbox-errors:text <- get *sandbox, errors:offset
+    break-unless sandbox-errors
+    *sandbox <- put *sandbox, response-starting-row-on-screen:offset, 0  # no response
+    row, screen <- render-text screen, sandbox-errors, left, right, 1/red, row
+    # don't try to print anything more for this sandbox
+    jump +render-sandbox-end
+  }
+]
+
+scenario run-shows-errors-in-get [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  get 123:num, foo:offset|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  get 123:num, foo:offset                         ┊─────────────────────────────────────────────────.
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  get 123:num, foo:offset                         ┊─────────────────────────────────────────────────.
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: unknown element 'foo' in container 'number'  ┊                                                 .
+    .foo: first ingredient of 'get' should be a contai↩┊                                                 .
+    .ner, but got '123:num'                            ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  screen-should-contain-in-color 1/red, [
+    .  errors found                                                                                      .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .foo: unknown element 'foo' in container 'number'                                                    .
+    .foo: first ingredient of 'get' should be a contai                                                   .
+    .ner, but got '123:num'                                                                              .
+    .                                                                                                    .
+  ]
+]
+
+scenario run-shows-errors-without-final-newline-in-recipe-side [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen
+  render-all screen, env, render
+  assume-console [
+    type [recipe foo x [
+]]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo x [                                    ┊                                                 .
+    .]                                                 ┊─────────────────────────────────────────────────.
+    .foo: ingredient 'x' has no type                   ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-updates-status-with-first-erroneous-sandbox [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  assume-console [
+    left-click 3, 80
+    # create invalid sandbox 1
+    type [get foo, x:offset]
+    press F4
+    # create invalid sandbox 0
+    type [get foo, x:offset]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # status line shows that error is in first sandbox
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+  ]
+]
+
+scenario run-updates-status-with-first-erroneous-sandbox-2 [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, []
+  render-all screen, env, render
+  assume-console [
+    left-click 3, 80
+    # create invalid sandbox 2
+    type [get foo, x:offset]
+    press F4
+    # create invalid sandbox 1
+    type [get foo, x:offset]
+    press F4
+    # create valid sandbox 0
+    type [add 2, 2]
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # status line shows that error is in second sandbox
+  screen-should-contain [
+    .  errors found (1)                                                               run (F4)           .
+  ]
+]
+
+scenario run-hides-errors-from-past-sandboxes [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [get foo, x:offset]  # invalid
+  render-all screen, env, render
+  assume-console [
+    press F4  # generate error
+  ]
+  event-loop screen, console, env, resources
+  assume-console [
+    left-click 3, 58
+    press ctrl-k
+    type [add 2, 2]  # valid code
+    press F4  # update sandbox
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # error should disappear
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊add 2, 2                                         .
+    .                                                  ┊4                                                .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-updates-errors-for-shape-shifting-recipes [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # define a shape-shifting recipe with an error
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo x:_elem -> z:_elem [|
+      |  local-scope|
+      |  load-ingredients|
+      |  y:&:num <- copy null|
+      |  z <- add x, y|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo 2]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .recipe foo x:_elem -> z:_elem [                   ┊                                                 .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  load-ingredients                                ┊0   edit       copy       to recipe    delete    .
+    .  y:&:num <- copy null                            ┊foo 2                                            .
+    .  z <- add x, y                                   ┊foo_2: 'add' requires number ingredients, but go↩.
+    .]                                                 ┊t 'y'                                            .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # now rerun everything
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # error should remain unchanged
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .recipe foo x:_elem -> z:_elem [                   ┊                                                 .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  load-ingredients                                ┊0   edit       copy       to recipe    delete    .
+    .  y:&:num <- copy null                            ┊foo 2                                            .
+    .  z <- add x, y                                   ┊foo_3: 'add' requires number ingredients, but go↩.
+    .]                                                 ┊t 'y'                                            .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-avoids-spurious-errors-on-reloading-shape-shifting-recipes [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # overload a well-known shape-shifting recipe
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe length l:&:list:_elem -> n:num [|
+      |]|
+    ]
+  ]
+  # call code that uses other variants of it, but not it itself
+  test-sandbox:text <- new [x:&:list:num <- copy null
+to-text x]
+  env:&:environment <- new-programming-environment resources, screen, test-sandbox
+  render-all screen, env, render
+  # run it once
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  # no errors anywhere on screen (can't check anything else, since to-text will return an address)
+  screen-should-contain-in-color 1/red, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                <-                                  .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+  # rerun everything
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # still no errors
+  screen-should-contain-in-color 1/red, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                <-                                  .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+]
+
+scenario run-shows-missing-type-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  x <- copy 0|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  x <- copy 0                                     ┊─────────────────────────────────────────────────.
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: missing type for 'x' in 'x <- copy 0'        ┊                                                 .
+    .foo: can't copy '0' to 'x'; types don't match     ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-shows-unbalanced-bracket-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # recipe is incomplete (unbalanced '[')
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo \\\[|
+      |  x <- copy 0|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo \\[                                      ┊foo                                              .
+    .  x <- copy 0                                     ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .9: unbalanced '\\[' for recipe                      ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-shows-get-on-non-container-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  local-scope|
+      |  x:&:point <- new point:type|
+      |  get x:&:point, 1:offset|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:&:point <- new point:type                     ┊                                                 .
+    .  get x:&:point, 1:offset                         ┊                                                 .
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: first ingredient of 'get' should be a contai↩┊                                                 .
+    .ner, but got 'x:&:point'                          ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-shows-non-literal-get-argument-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  local-scope|
+      |  x:num <- copy 0|
+      |  y:&:point <- new point:type|
+      |  get *y:&:point, x:num|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:num <- copy 0                                 ┊                                                 .
+    .  y:&:point <- new point:type                     ┊                                                 .
+    .  get *y:&:point, x:num                           ┊                                                 .
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: second ingredient of 'get' should have type ↩┊                                                 .
+    .'offset', but got 'x:num'                         ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-shows-errors-every-time [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # try to run a file with an error
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  local-scope|
+      |  x:num <- copy y:num|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:num <- copy y:num                             ┊                                                 .
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: tried to read ingredient 'y' in 'x:num <- co↩┊                                                 .
+    .py y:num' but it hasn't been written to yet       ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # rerun the file, check for the same error
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:num <- copy y:num                             ┊                                                 .
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: tried to read ingredient 'y' in 'x:num <- co↩┊                                                 .
+    .py y:num' but it hasn't been written to yet       ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-hides-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 15/height
+  # try to run a file with an error
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  local-scope|
+      |  x:num <- copy y:num|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .recipe foo [                                      ┊foo                                              .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:num <- copy y:num                             ┊                                                 .
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+    .foo: tried to read ingredient 'y' in 'x:num <- co↩┊                                                 .
+    .py y:num' but it hasn't been written to yet       ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # fix the error, hit F4
+  assume-console [
+    left-click 3, 16
+    press ctrl-k
+    type [0]
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  # no error anymore
+  screen-should-contain [
+    .                                                                                 run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  x:num <- copy 0                                 ┊0   edit       copy       to recipe    delete    .
+    .]                                                 ┊foo                                              .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario scrolling-recipe-side-reveals-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 5/height
+  # recipe overflows recipe side
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  a:num <- copy 0|  # padding to overflow recipe side
+      |  b:num <- copy 0|  # padding to overflow recipe side
+      |  get 123:num, foo:offset|  # line containing error
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  # hit F4, generating errors, then scroll down
+  assume-console [
+    press F4
+    press page-down
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # errors should be displayed
+  screen-should-contain [
+    .  errors found                                                                   run (F4)           .
+    .  get 123:num, foo:offset                         ┊foo                                              .
+    .\\]                                                 ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .foo: unknown element 'foo' in container 'number'  ┊                                                 .
+  ]
+]
+
+scenario run-instruction-and-print-errors [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  # sandbox editor contains an illegal instruction
+  env:&:environment <- new-programming-environment resources, screen, [get 1234:num, foo:offset]
+  render-all screen, env, render
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that screen prints error message in red
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊get 1234:num, foo:offset                         .
+    .                                                  ┊unknown element 'foo' in container 'number'      .
+    .                                                  ┊first ingredient of 'get' should be a container,↩.
+    .                                                  ┊ but got '1234:num'                              .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+  screen-should-contain-in-color 7/white, [
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   get 1234:num, foo:offset                         .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+  ]
+  screen-should-contain-in-color 1/red, [
+    .  errors found (0)                                                                                  .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                                                                    .
+    .                                                   unknown element 'foo' in container 'number'      .
+    .                                                   first ingredient of 'get' should be a container, .
+    .                                                    but got '1234:num'                              .
+    .                                                                                                    .
+  ]
+  screen-should-contain-in-color 245/grey, [
+    .                                                                                                    .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                 .
+    .                                                  ┊                                                ↩.
+    .                                                  ┊                                                 .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario run-instruction-and-print-errors-only-once [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  assume-resources [
+  ]
+  # sandbox editor contains an illegal instruction
+  env:&:environment <- new-programming-environment resources, screen, [get 1234:num, foo:offset]
+  render-all screen, env, render
+  # run the code in the editors multiple times
+  assume-console [
+    press F4
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # check that screen prints error message just once
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .                                                  ┊                                                 .
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊─────────────────────────────────────────────────.
+    .                                                  ┊0   edit       copy       to recipe    delete    .
+    .                                                  ┊get 1234:num, foo:offset                         .
+    .                                                  ┊unknown element 'foo' in container 'number'      .
+    .                                                  ┊first ingredient of 'get' should be a container,↩.
+    .                                                  ┊ but got '1234:num'                              .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario sandbox-can-handle-infinite-loop [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 20/height
+  # sandbox editor will trigger an infinite loop
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  {|
+      |    loop|
+      |  }|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo]
+  render-all screen, env, render
+  # run the sandbox
+  assume-console [
+    press F4
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  {                                               ┊─────────────────────────────────────────────────.
+    .    loop                                          ┊0   edit       copy       to recipe    delete    .
+    .  }                                               ┊foo                                              .
+    .]                                                 ┊took too long!                                   .
+    .                                                  ┊─────────────────────────────────────────────────.
+    .┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+]
+
+scenario sandbox-with-errors-shows-trace [
+  local-scope
+  trace-until 100/app  # trace too long
+  assume-screen 100/width, 10/height
+  # generate a stash and a error
+  assume-resources [
+    [lesson/recipes.mu] <- [
+      |recipe foo [|
+      |  local-scope|
+      |  a:num <- next-ingredient|
+      |  b:num <- next-ingredient|
+      |  stash [dividing by], b|
+      |  _, c:num <- divide-with-remainder a, b|
+      |  reply b|
+      |]|
+    ]
+  ]
+  env:&:environment <- new-programming-environment resources, screen, [foo 4, 0]
+  render-all screen, env, render
+  # run
+  assume-console [
+    press F4
+  ]
+  event-loop screen, console, env, resources
+  # screen prints error message
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  a:num <- next-ingredient                        ┊0   edit       copy       to recipe    delete    .
+    .  b:num <- next-ingredient                        ┊foo 4, 0                                         .
+    .  stash [dividing by], b                          ┊foo: divide by zero in '_, c:num <- divide-with-↩.
+    .  _, c:num <- divide-with-remainder a, b          ┊remainder a, b'                                  .
+    .  reply b                                         ┊─────────────────────────────────────────────────.
+    .]                                                 ┊                                                 .
+    .                                                  ┊                                                 .
+  ]
+  # click on the call in the sandbox
+  assume-console [
+    left-click 4, 55
+  ]
+  run [
+    event-loop screen, console, env, resources
+  ]
+  # screen should expand trace
+  screen-should-contain [
+    .  errors found (0)                                                               run (F4)           .
+    .recipe foo [                                      ┊                                                 .
+    .  local-scope                                     ┊─────────────────────────────────────────────────.
+    .  a:num <- next-ingredient                        ┊0   edit       copy       to recipe    delete    .
+    .  b:num <- next-ingredient                        ┊foo 4, 0                                         .
+    .  stash [dividing by], b                          ┊dividing by 0                                    .
+    .  _, c:num <- divide-with-remainder a, b          ┊14 instructions run                              .
+    .  reply b                                         ┊foo: divide by zero in '_, c:num <- divide-with-↩.
+    .]                                                 ┊remainder a, b'                                  .
+    .                                                  ┊─────────────────────────────────────────────────.
+  ]
+]
diff --git a/archive/2.vm/edit/012-editor-undo.mu b/archive/2.vm/edit/012-editor-undo.mu
new file mode 100644
index 00000000..871f6c74
--- /dev/null
+++ b/archive/2.vm/edit/012-editor-undo.mu
@@ -0,0 +1,2111 @@
+## undo/redo
+
+# for every undoable event, create a type of *operation* that contains all the
+# information needed to reverse it
+exclusive-container operation [
+  typing:insert-operation
+  move:move-operation
+  delete:delete-operation
+]
+
+container insert-operation [
+  before-row:num
+  before-column:num
+  before-top-of-screen:&:duplex-list:char
+  after-row:num
+  after-column:num
+  after-top-of-screen:&:duplex-list:char
+  # inserted text is from 'insert-from' until 'insert-until'; list doesn't have to terminate
+  insert-from:&:duplex-list:char
+  insert-until:&:duplex-list:char
+  tag:num  # event causing this operation; might be used to coalesce runs of similar events
+    # 0: no coalesce (enter+indent)
+    # 1: regular alphanumeric characters
+]
+
+container move-operation [
+  before-row:num
+  before-column:num
+  before-top-of-screen:&:duplex-list:char
+  after-row:num
+  after-column:num
+  after-top-of-screen:&:duplex-list:char
+  tag:num  # event causing this operation; might be used to coalesce runs of similar events
+    # 0: no coalesce (touch events, etc)
+    # 1: left arrow
+    # 2: right arrow
+    # 3: up arrow
+    # 4: down arrow
+    # 5: line up
+    # 6: line down
+]
+
+container delete-operation [
+  before-row:num
+  before-column:num
+  before-top-of-screen:&:duplex-list:char
+  after-row:num
+  after-column:num
+  after-top-of-screen:&:duplex-list:char
+  deleted-text:&:duplex-list:char
+  delete-from:&:duplex-list:char
+  delete-until:&:duplex-list:char
+  tag:num  # event causing this operation; might be used to coalesce runs of similar events
+    # 0: no coalesce (ctrl-k, ctrl-u)
+    # 1: backspace
+    # 2: delete
+]
+
+# every editor accumulates a list of operations to undo/redo
+container editor [
+  undo:&:list:&:operation
+  redo:&:list:&:operation
+]
+
+# ctrl-z - undo operation
+after <handle-special-character> [
+  {
+    undo?:bool <- equal c, 26/ctrl-z
+    break-unless undo?
+    undo:&:list:&:operation <- get *editor, undo:offset
+    break-unless undo
+    op:&:operation <- first undo
+    undo <- rest undo
+    *editor <- put *editor, undo:offset, undo
+    redo:&:list:&:operation <- get *editor, redo:offset
+    redo <- push op, redo
+    *editor <- put *editor, redo:offset, redo
+    <handle-undo>
+    return true/go-render
+  }
+]
+
+# ctrl-y - redo operation
+after <handle-special-character> [
+  {
+    redo?:bool <- equal c, 25/ctrl-y
+    break-unless redo?
+    redo:&:list:&:operation <- get *editor, redo:offset
+    break-unless redo
+    op:&:operation <- first redo
+    redo <- rest redo
+    *editor <- put *editor, redo:offset, redo
+    undo:&:list:&:operation <- get *editor, undo:offset
+    undo <- push op, undo
+    *editor <- put *editor, undo:offset, undo
+    <handle-redo>
+    return true/go-render
+  }
+]
+
+# undo typing
+
+scenario editor-can-undo-typing [
+  local-scope
+  # create an editor and type a character
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [0]
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # character should be gone
+  screen-should-contain [
+    .          .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .1         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+# save operation to undo
+after <begin-insert-character> [
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+  cursor-before:&:duplex-list:char <- get *editor, before-cursor:offset
+]
+before <end-insert-character> [
+  top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  undo:&:list:&:operation <- get *editor, undo:offset
+  {
+    # if previous operation was an insert, coalesce this operation with it
+    break-unless undo
+    op:&:operation <- first undo
+    typing:insert-operation, is-insert?:bool <- maybe-convert *op, typing:variant
+    break-unless is-insert?
+    previous-coalesce-tag:num <- get typing, tag:offset
+    break-unless previous-coalesce-tag
+    before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+    insert-until:&:duplex-list:char <- next before-cursor
+    typing <- put typing, insert-until:offset, insert-until
+    typing <- put typing, after-row:offset, cursor-row
+    typing <- put typing, after-column:offset, cursor-column
+    typing <- put typing, after-top-of-screen:offset, top-after
+    *op <- merge 0/insert-operation, typing
+    break +done-adding-insert-operation
+  }
+  # if not, create a new operation
+  insert-from:&:duplex-list:char <- next cursor-before
+  insert-to:&:duplex-list:char <- next insert-from
+  op:&:operation <- new operation:type
+  *op <- merge 0/insert-operation, save-row/before, save-column/before, top-before, cursor-row/after, cursor-column/after, top-after, insert-from, insert-to, 1/coalesce
+  editor <- add-operation editor, op
+  +done-adding-insert-operation
+]
+
+# enter operations never coalesce with typing before or after
+after <begin-insert-enter> [
+  cursor-row-before:num <- copy cursor-row
+  cursor-column-before:num <- copy cursor-column
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+  cursor-before:&:duplex-list:char <- get *editor, before-cursor:offset
+]
+before <end-insert-enter> [
+  top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-row:offset
+  # never coalesce
+  insert-from:&:duplex-list:char <- next cursor-before
+  before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+  insert-to:&:duplex-list:char <- next before-cursor
+  op:&:operation <- new operation:type
+  *op <- merge 0/insert-operation, cursor-row-before, cursor-column-before, top-before, cursor-row/after, cursor-column/after, top-after, insert-from, insert-to, 0/never-coalesce
+  editor <- add-operation editor, op
+]
+
+# Everytime you add a new operation to the undo stack, be sure to clear the
+# redo stack, because it's now obsolete.
+# Beware: since we're counting cursor moves as operations, this means just
+# moving the cursor can lose work on the undo stack.
+def add-operation editor:&:editor, op:&:operation -> editor:&:editor [
+  local-scope
+  load-inputs
+  undo:&:list:&:operation <- get *editor, undo:offset
+  undo <- push op undo
+  *editor <- put *editor, undo:offset, undo
+  redo:&:list:&:operation <- get *editor, redo:offset
+  redo <- copy null
+  *editor <- put *editor, redo:offset, redo
+]
+
+after <handle-undo> [
+  {
+    typing:insert-operation, is-insert?:bool <- maybe-convert *op, typing:variant
+    break-unless is-insert?
+    start:&:duplex-list:char <- get typing, insert-from:offset
+    end:&:duplex-list:char <- get typing, insert-until:offset
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    before-cursor:&:duplex-list:char <- prev start
+    *editor <- put *editor, before-cursor:offset, before-cursor
+    remove-between before-cursor, end
+    cursor-row <- get typing, before-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get typing, before-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get typing, before-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+scenario editor-can-undo-typing-multiple [
+  local-scope
+  # create an editor and type multiple characters
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [012]
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # all characters must be gone
+  screen-should-contain [
+    .          .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-can-undo-typing-multiple-2 [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [a], 0/left, 10/right
+  editor-render screen, e
+  # type some characters
+  assume-console [
+    type [012]
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .012a      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # back to original text
+  screen-should-contain [
+    .          .
+    .a         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [3]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .3a        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-can-undo-typing-enter [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [  abc], 0/left, 10/right
+  editor-render screen, e
+  # new line
+  assume-console [
+    left-click 1, 8
+    press enter
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .  abc     .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # line is indented
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 2
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 5
+  ]
+  # back to original text
+  screen-should-contain [
+    .          .
+    .  abc     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor should be at end of line
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .  abc1    .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+# redo typing
+
+scenario editor-redo-typing [
+  local-scope
+  # create an editor, type something, undo
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [a], 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [012]
+    press ctrl-z
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # all characters must be back
+  screen-should-contain [
+    .          .
+    .012a      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [3]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0123a     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <handle-redo> [
+  {
+    typing:insert-operation, is-insert?:bool <- maybe-convert *op, typing:variant
+    break-unless is-insert?
+    before-cursor <- get *editor, before-cursor:offset
+    insert-from:&:duplex-list:char <- get typing, insert-from:offset  # ignore insert-to because it's already been spliced away
+    # assert insert-to matches next(before-cursor)
+    splice before-cursor, insert-from
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    cursor-row <- get typing, after-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get typing, after-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get typing, after-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+scenario editor-redo-typing-empty [
+  local-scope
+  # create an editor, type something, undo
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [012]
+    press ctrl-z
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # all characters must be back
+  screen-should-contain [
+    .          .
+    .012       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [3]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .0123      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+scenario editor-work-clears-redo-stack [
+  local-scope
+  # create an editor with some text, do some work, undo
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [1]
+    press ctrl-z
+  ]
+  editor-event-loop screen, console, e
+  # do some more work
+  assume-console [
+    type [0]
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .0abc      .
+    .def       .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # nothing should happen
+  screen-should-contain [
+    .          .
+    .0abc      .
+    .def       .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-redo-typing-and-enter-and-tab [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  # insert some text and tabs, hit enter, some more text and tabs
+  assume-console [
+    press tab
+    type [ab]
+    press tab
+    type [cd]
+    press enter
+    press tab
+    type [efg]
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .    efg   .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 7
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # typing in second line deleted, but not indent
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 2
+  ]
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # undo again
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # indent and newline deleted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 8
+  ]
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # undo again
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # empty screen
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  screen-should-contain [
+    .          .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 8
+  ]
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo again
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # newline and indent inserted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 2
+  ]
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo again
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # indent and newline deleted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 7
+  ]
+  screen-should-contain [
+    .          .
+    .  ab  cd  .
+    .    efg   .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+# undo cursor movement and scroll
+
+scenario editor-can-undo-touch [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor
+  assume-console [
+    left-click 3, 1
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # click undone
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .1abc      .
+    .def       .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+after <begin-move-cursor> [
+  cursor-row-before:num <- get *editor, cursor-row:offset
+  cursor-column-before:num <- get *editor, cursor-column:offset
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+]
+before <end-move-cursor> [
+  top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+  cursor-row:num <- get *editor, cursor-row:offset
+  cursor-column:num <- get *editor, cursor-column:offset
+  {
+    break-unless undo-coalesce-tag
+    # if previous operation was also a move, and also had the same coalesce
+    # tag, coalesce with it
+    undo:&:list:&:operation <- get *editor, undo:offset
+    break-unless undo
+    op:&:operation <- first undo
+    move:move-operation, is-move?:bool <- maybe-convert *op, move:variant
+    break-unless is-move?
+    previous-coalesce-tag:num <- get move, tag:offset
+    coalesce?:bool <- equal undo-coalesce-tag, previous-coalesce-tag
+    break-unless coalesce?
+    move <- put move, after-row:offset, cursor-row
+    move <- put move, after-column:offset, cursor-column
+    move <- put move, after-top-of-screen:offset, top-after
+    *op <- merge 1/move-operation, move
+    break +done-adding-move-operation
+  }
+  op:&:operation <- new operation:type
+  *op <- merge 1/move-operation, cursor-row-before, cursor-column-before, top-before, cursor-row/after, cursor-column/after, top-after, undo-coalesce-tag
+  editor <- add-operation editor, op
+  +done-adding-move-operation
+]
+
+after <handle-undo> [
+  {
+    move:move-operation, is-move?:bool <- maybe-convert *op, move:variant
+    break-unless is-move?
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    cursor-row <- get move, before-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get move, before-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get move, before-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+scenario editor-can-undo-scroll [
+  local-scope
+  # screen has 1 line for menu + 3 lines
+  assume-screen 5/width, 4/height
+  # editor contains a wrapped line
+  contents:text <- new [a
+b
+cdefgh]
+  e:&:editor <- new-editor contents, 0/left, 5/right
+  # position cursor at end of screen and try to move right
+  assume-console [
+    left-click 3, 3
+    press right-arrow
+  ]
+  editor-event-loop screen, console, e
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  # screen scrolls
+  screen-should-contain [
+    .     .
+    .b    .
+    .cdef↩.
+    .gh   .
+  ]
+  memory-should-contain [
+    3 <- 3
+    4 <- 0
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moved back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 3
+    4 <- 3
+  ]
+  # scroll undone
+  screen-should-contain [
+    .     .
+    .a    .
+    .b    .
+    .cdef↩.
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .     .
+    .b    .
+    .cde1↩.
+    .fgh  .
+  ]
+]
+
+scenario editor-can-undo-left-arrow [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor
+  assume-console [
+    left-click 3, 1
+    press left-arrow
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 3
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .g1hi      .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-up-arrow [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor
+  assume-console [
+    left-click 3, 1
+    press up-arrow
+  ]
+  editor-event-loop screen, console, e
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 3
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .g1hi      .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-down-arrow [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor
+  assume-console [
+    left-click 2, 1
+    press down-arrow
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .d1ef      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-ctrl-f [
+  local-scope
+  # create an editor with multiple pages of text
+  assume-screen 10/width, 5/height
+  contents:text <- new [a
+b
+c
+d
+e
+f]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # scroll the page
+  assume-console [
+    press ctrl-f
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen should again show page 1
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+    .d         .
+  ]
+]
+
+scenario editor-can-undo-page-down [
+  local-scope
+  # create an editor with multiple pages of text
+  assume-screen 10/width, 5/height
+  contents:text <- new [a
+b
+c
+d
+e
+f]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # scroll the page
+  assume-console [
+    press page-down
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen should again show page 1
+  screen-should-contain [
+    .          .
+    .a         .
+    .b         .
+    .c         .
+    .d         .
+  ]
+]
+
+scenario editor-can-undo-ctrl-b [
+  local-scope
+  # create an editor with multiple pages of text
+  assume-screen 10/width, 5/height
+  contents:text <- new [a
+b
+c
+d
+e
+f]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # scroll the page down and up
+  assume-console [
+    press page-down
+    press ctrl-b
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen should again show page 2
+  screen-should-contain [
+    .          .
+    .d         .
+    .e         .
+    .f         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-page-up [
+  local-scope
+  # create an editor with multiple pages of text
+  assume-screen 10/width, 5/height
+  contents:text <- new [a
+b
+c
+d
+e
+f]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # scroll the page down and up
+  assume-console [
+    press page-down
+    press page-up
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # screen should again show page 2
+  screen-should-contain [
+    .          .
+    .d         .
+    .e         .
+    .f         .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-ctrl-a [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor, then to start of line
+  assume-console [
+    left-click 2, 1
+    press ctrl-a
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .d1ef      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-home [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor, then to start of line
+  assume-console [
+    left-click 2, 1
+    press home
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .d1ef      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-ctrl-e [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor, then to start of line
+  assume-console [
+    left-click 2, 1
+    press ctrl-e
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .d1ef      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-end [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor, then to start of line
+  assume-console [
+    left-click 2, 1
+    press end
+  ]
+  editor-event-loop screen, console, e
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves back
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .d1ef      .
+    .ghi       .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+scenario editor-can-undo-multiple-arrows-in-the-same-direction [
+  local-scope
+  # create an editor with some text
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # move the cursor
+  assume-console [
+    left-click 2, 1
+    press right-arrow
+    press right-arrow
+    press up-arrow
+  ]
+  editor-event-loop screen, console, e
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # up-arrow is undone
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 3
+  ]
+  # undo again
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # both right-arrows are undone
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 2
+    4 <- 1
+  ]
+]
+
+# redo cursor movement and scroll
+
+scenario editor-redo-touch [
+  local-scope
+  # create an editor with some text, click on a character, undo
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def
+ghi]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    left-click 3, 1
+    press ctrl-z
+  ]
+  editor-event-loop screen, console, e
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # cursor moves to left-click
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 3
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .g1hi      .
+    .┈┈┈┈┈┈┈┈┈┈.
+  ]
+]
+
+after <handle-redo> [
+  {
+    move:move-operation, is-move?:bool <- maybe-convert *op, move:variant
+    break-unless is-move?
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    cursor-row <- get move, after-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get move, after-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get move, after-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+scenario editor-separates-undo-insert-from-undo-cursor-move [
+  local-scope
+  # create an editor, type some text, move the cursor, type some more text
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  assume-console [
+    type [abc]
+    left-click 1, 1
+    type [d]
+  ]
+  editor-event-loop screen, console, e
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  screen-should-contain [
+    .          .
+    .adbc      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # last letter typed is deleted
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # undo again
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # no change to screen; cursor moves
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  # undo again
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # screen empty
+  screen-should-contain [
+    .          .
+    .          .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # first insert
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  # redo again
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # cursor moves
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # cursor moves
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # redo again
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+    3:num/raw <- get *e, cursor-row:offset
+    4:num/raw <- get *e, cursor-column:offset
+  ]
+  # second insert
+  screen-should-contain [
+    .          .
+    .adbc      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+]
+
+# undo backspace
+
+scenario editor-can-undo-and-redo-backspace [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  # insert some text and hit backspace
+  assume-console [
+    type [abc]
+    press backspace
+    press backspace
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 3
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  screen-should-contain [
+    .          .
+    .a         .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+# save operation to undo
+after <begin-backspace-character> [
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+]
+before <end-backspace-character> [
+  {
+    break-unless backspaced-cell  # backspace failed; don't add an undo operation
+    top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+    cursor-row:num <- get *editor, cursor-row:offset
+    cursor-column:num <- get *editor, cursor-row:offset
+    before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+    undo:&:list:&:operation <- get *editor, undo:offset
+    {
+      # if previous operation was an insert, coalesce this operation with it
+      break-unless undo
+      op:&:operation <- first undo
+      deletion:delete-operation, is-delete?:bool <- maybe-convert *op, delete:variant
+      break-unless is-delete?
+      previous-coalesce-tag:num <- get deletion, tag:offset
+      coalesce?:bool <- equal previous-coalesce-tag, 1/coalesce-backspace
+      break-unless coalesce?
+      deletion <- put deletion, delete-from:offset, before-cursor
+      backspaced-so-far:&:duplex-list:char <- get deletion, deleted-text:offset
+      splice backspaced-cell, backspaced-so-far
+      deletion <- put deletion, deleted-text:offset, backspaced-cell
+      deletion <- put deletion, after-row:offset, cursor-row
+      deletion <- put deletion, after-column:offset, cursor-column
+      deletion <- put deletion, after-top-of-screen:offset, top-after
+      *op <- merge 2/delete-operation, deletion
+      break +done-adding-backspace-operation
+    }
+    # if not, create a new operation
+    op:&:operation <- new operation:type
+    deleted-until:&:duplex-list:char <- next before-cursor
+    *op <- merge 2/delete-operation, save-row/before, save-column/before, top-before, cursor-row/after, cursor-column/after, top-after, backspaced-cell/deleted, before-cursor/delete-from, deleted-until, 1/coalesce-backspace
+    editor <- add-operation editor, op
+    +done-adding-backspace-operation
+  }
+]
+
+after <handle-undo> [
+  {
+    deletion:delete-operation, is-delete?:bool <- maybe-convert *op, delete:variant
+    break-unless is-delete?
+    anchor:&:duplex-list:char <- get deletion, delete-from:offset
+    break-unless anchor
+    deleted:&:duplex-list:char <- get deletion, deleted-text:offset
+    old-cursor:&:duplex-list:char <- last deleted
+    splice anchor, deleted
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    before-cursor <- copy old-cursor
+    cursor-row <- get deletion, before-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get deletion, before-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get deletion, before-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+after <handle-redo> [
+  {
+    deletion:delete-operation, is-delete?:bool <- maybe-convert *op, delete:variant
+    break-unless is-delete?
+    start:&:duplex-list:char <- get deletion, delete-from:offset
+    end:&:duplex-list:char <- get deletion, delete-until:offset
+    data:&:duplex-list:char <- get *editor, data:offset
+    remove-between start, end
+    # assert cursor-row/cursor-column/top-of-screen match after-row/after-column/after-top-of-screen
+    cursor-row <- get deletion, after-row:offset
+    *editor <- put *editor, cursor-row:offset, cursor-row
+    cursor-column <- get deletion, after-column:offset
+    *editor <- put *editor, cursor-column:offset, cursor-column
+    top:&:duplex-list:char <- get deletion, before-top-of-screen:offset
+    *editor <- put *editor, top-of-screen:offset, top
+  }
+]
+
+# undo delete
+
+scenario editor-can-undo-and-redo-delete [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  # insert some text and hit delete and backspace a few times
+  assume-console [
+    type [abcdef]
+    left-click 1, 2
+    press delete
+    press backspace
+    press delete
+    press delete
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .af        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # undo deletes
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  screen-should-contain [
+    .          .
+    .adef      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # undo backspace
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  screen-should-contain [
+    .          .
+    .abdef     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # undo first delete
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  screen-should-contain [
+    .          .
+    .abcdef    .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo first delete
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  screen-should-contain [
+    .          .
+    .abdef     .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo backspace
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  screen-should-contain [
+    .          .
+    .adef      .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  # redo deletes
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  screen-should-contain [
+    .          .
+    .af        .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <begin-delete-character> [
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+]
+before <end-delete-character> [
+  {
+    break-unless deleted-cell  # delete failed; don't add an undo operation
+    top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+    cursor-row:num <- get *editor, cursor-row:offset
+    cursor-column:num <- get *editor, cursor-column:offset
+    before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+    undo:&:list:&:operation <- get *editor, undo:offset
+    {
+      # if previous operation was an insert, coalesce this operation with it
+      break-unless undo
+      op:&:operation <- first undo
+      deletion:delete-operation, is-delete?:bool <- maybe-convert *op, delete:variant
+      break-unless is-delete?
+      previous-coalesce-tag:num <- get deletion, tag:offset
+      coalesce?:bool <- equal previous-coalesce-tag, 2/coalesce-delete
+      break-unless coalesce?
+      delete-until:&:duplex-list:char <- next before-cursor
+      deletion <- put deletion, delete-until:offset, delete-until
+      deleted-so-far:&:duplex-list:char <- get deletion, deleted-text:offset
+      deleted-so-far <- append deleted-so-far, deleted-cell
+      deletion <- put deletion, deleted-text:offset, deleted-so-far
+      deletion <- put deletion, after-row:offset, cursor-row
+      deletion <- put deletion, after-column:offset, cursor-column
+      deletion <- put deletion, after-top-of-screen:offset, top-after
+      *op <- merge 2/delete-operation, deletion
+      break +done-adding-delete-operation
+    }
+    # if not, create a new operation
+    op:&:operation <- new operation:type
+    deleted-until:&:duplex-list:char <- next before-cursor
+    *op <- merge 2/delete-operation, save-row/before, save-column/before, top-before, cursor-row/after, cursor-column/after, top-after, deleted-cell/deleted, before-cursor/delete-from, deleted-until, 2/coalesce-delete
+    editor <- add-operation editor, op
+    +done-adding-delete-operation
+  }
+]
+
+# undo ctrl-k
+
+scenario editor-can-undo-and-redo-ctrl-k [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # insert some text and hit delete and backspace a few times
+  assume-console [
+    left-click 1, 1
+    press ctrl-k
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .a         .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  screen-should-contain [
+    .          .
+    .a         .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 1
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .a1        .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <begin-delete-to-end-of-line> [
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+]
+before <end-delete-to-end-of-line> [
+  {
+    break-unless deleted-cells  # delete failed; don't add an undo operation
+    top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+    cursor-row:num <- get *editor, cursor-row:offset
+    cursor-column:num <- get *editor, cursor-column:offset
+    deleted-until:&:duplex-list:char <- next before-cursor
+    op:&:operation <- new operation:type
+    *op <- merge 2/delete-operation, save-row/before, save-column/before, top-before, cursor-row/after, cursor-column/after, top-after, deleted-cells/deleted, before-cursor/delete-from, deleted-until, 0/never-coalesce
+    editor <- add-operation editor, op
+    +done-adding-delete-operation
+  }
+]
+
+# undo ctrl-u
+
+scenario editor-can-undo-and-redo-ctrl-u [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  contents:text <- new [abc
+def]
+  e:&:editor <- new-editor contents, 0/left, 10/right
+  editor-render screen, e
+  # insert some text and hit delete and backspace a few times
+  assume-console [
+    left-click 1, 2
+    press ctrl-u
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .c         .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  # undo
+  assume-console [
+    press ctrl-z
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .abc       .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 2
+  ]
+  # redo
+  assume-console [
+    press ctrl-y
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  # first line inserted
+  screen-should-contain [
+    .          .
+    .c         .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+  3:num/raw <- get *e, cursor-row:offset
+  4:num/raw <- get *e, cursor-column:offset
+  memory-should-contain [
+    3 <- 1
+    4 <- 0
+  ]
+  # cursor should be in the right place
+  assume-console [
+    type [1]
+  ]
+  run [
+    editor-event-loop screen, console, e
+  ]
+  screen-should-contain [
+    .          .
+    .1c        .
+    .def       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
+
+after <begin-delete-to-start-of-line> [
+  top-before:&:duplex-list:char <- get *editor, top-of-screen:offset
+]
+before <end-delete-to-start-of-line> [
+  {
+    break-unless deleted-cells  # delete failed; don't add an undo operation
+    top-after:&:duplex-list:char <- get *editor, top-of-screen:offset
+    op:&:operation <- new operation:type
+    before-cursor:&:duplex-list:char <- get *editor, before-cursor:offset
+    deleted-until:&:duplex-list:char <- next before-cursor
+    cursor-row:num <- get *editor, cursor-row:offset
+    cursor-column:num <- get *editor, cursor-column:offset
+    *op <- merge 2/delete-operation, save-row/before, save-column/before, top-before, cursor-row/after, cursor-column/after, top-after, deleted-cells/deleted, before-cursor/delete-from, deleted-until, 0/never-coalesce
+    editor <- add-operation editor, op
+    +done-adding-delete-operation
+  }
+]
+
+scenario editor-can-undo-and-redo-ctrl-u-2 [
+  local-scope
+  # create an editor
+  assume-screen 10/width, 5/height
+  e:&:editor <- new-editor [], 0/left, 10/right
+  editor-render screen, e
+  # insert some text and hit delete and backspace a few times
+  assume-console [
+    type [abc]
+    press ctrl-u
+    press ctrl-z
+  ]
+  editor-event-loop screen, console, e
+  screen-should-contain [
+    .          .
+    .abc       .
+    .┈┈┈┈┈┈┈┈┈┈.
+    .          .
+  ]
+]
diff --git a/archive/2.vm/edit/Readme.md b/archive/2.vm/edit/Readme.md
new file mode 100644
index 00000000..698e534b
--- /dev/null
+++ b/archive/2.vm/edit/Readme.md
@@ -0,0 +1,49 @@
+Environment for learning programming using Mu: http://akkartik.name/post/mu
+
+Run it from the `mu` directory:
+
+  ```shell
+  $ ./mu edit
+  ```
+
+This will load all the `.mu` files in this directory and then run the editor.
+Press ctrl-c to quit. Press F4 to save your work (if a lesson/ directory
+exists) and to run the contents of the sandbox editor on the right.
+
+You can also run the tests for the environment:
+
+  ```shell
+  $ ./mu test edit
+  ```
+
+You can also load the files more explicitly by enumerating them all (in order):
+
+  ```shell
+  $  ./mu edit/*.mu
+  ```
+
+This is handy if you want to play with simpler versions of the editor that are
+easier to understand. Stop loading at any layer to run with a subset of
+features:
+
+  ```shell
+  $ ./mu edit/001*.mu edit/002*.mu  # run a simple editor rather than the full environment
+  ```
+
+---
+
+Appendix: keyboard shortcuts
+
+  _moving and scrolling_
+  - `ctrl-a` or `home`: move cursor to start of line
+  - `ctrl-e` or `end`: move cursor to end of line
+  - `ctrl-f` or `page-down`: scroll down by one page
+  - `ctrl-b` or `page-up`: scroll up by one page
+  - `ctrl-x`: scroll down by one line
+  - `ctrl-s`: scroll up by one line
+  - `ctrl-t`: scroll until current line is at top of screen
+
+  _modifying text_
+  - `ctrl-k`: delete text from cursor to end of line
+  - `ctrl-u`: delete text from start of line until just before cursor
+  - `ctrl-/`: comment/uncomment current line (using a special leader to ignore real comments https://www.reddit.com/r/vim/comments/4ootmz/_/d4ehmql)