about summary refs log tree commit diff stats
path: root/archive/1.vm/edit/003-shortcuts.mu
diff options
context:
space:
mode:
Diffstat (limited to 'archive/1.vm/edit/003-shortcuts.mu')
-rw-r--r--archive/1.vm/edit/003-shortcuts.mu4462
1 files changed, 4462 insertions, 0 deletions
diff --git a/archive/1.vm/edit/003-shortcuts.mu b/archive/1.vm/edit/003-shortcuts.mu
new file mode 100644
index 00000000..872dfcea
--- /dev/null
+++ b/archive/1.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        .
+    .┈┈┈┈┈     .
+    .          .
+  ]
+]